diff --git a/AGENTS.md b/AGENTS.md
index 343e33c..f353cde 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,7 +30,7 @@ Separate Python companion script in `scripts/ios-app-import/` (stdlib-only, Pyth
## Architecture
-This is a Next.js 16 App Router app (TypeScript, React 19) backed by a single local SQLite file. All scraping, parsing, diffing, and AI calls happen server-side inside API routes that import helpers from `lib/`.
+This is a Next.js 16 App Router app (TypeScript, React 19) backed by a single local SQLite file. All scraping, parsing, diffing, and AI calls happen server-side inside API routes that import helpers from `lib/`. End-to-end workflow diagrams (system map, import/delete/sync/wayback flows, gate chain) with known weak points marked live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
### Data flow (the core loop)
diff --git a/README.md b/README.md
index 31890b7..06992b1 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ Full documentation lives at
- [User guide](https://privacytracker-docs.privacykey.org/quickstart) — how to import apps, read privacy labels, set up alerts
- [AI provider setup](https://privacytracker-docs.privacykey.org/quickstart) — bring your own OpenAI / Anthropic / local model
- [Architecture](https://privacytracker-docs.privacykey.org/develop/architecture) — for developers and contributors
+- [Architecture & workflows (in-repo)](docs/ARCHITECTURE.md) — end-to-end diagrams of every process, with weak points marked and an improvement backlog
- [Security](https://privacytracker-docs.privacykey.org/security) — how to report a vulnerability
## License
diff --git a/app/api/device-actions/backup/route.ts b/app/api/device-actions/backup/route.ts
index 0c06c30..6620d4e 100644
--- a/app/api/device-actions/backup/route.ts
+++ b/app/api/device-actions/backup/route.ts
@@ -15,7 +15,7 @@
*/
import { type NextRequest, NextResponse } from "next/server";
-import { recordBackup } from "@/lib/device-actions";
+import { normalizeEcid, recordBackup } from "@/lib/device-actions";
import { getActiveFocus } from "@/lib/feature-flag-storage";
import { readBoundedJson } from "@/lib/security";
@@ -44,8 +44,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
- if (!body.ecid || typeof body.ecid !== "string") {
- return NextResponse.json({ error: "ecid is required" }, { status: 400 });
+ if (
+ !body.ecid ||
+ typeof body.ecid !== "string" ||
+ !normalizeEcid(body.ecid)
+ ) {
+ return NextResponse.json(
+ { error: "a valid ecid is required" },
+ { status: 400 }
+ );
}
if (!body.path || typeof body.path !== "string") {
return NextResponse.json({ error: "path is required" }, { status: 400 });
diff --git a/app/api/device-actions/uninstall/route.ts b/app/api/device-actions/uninstall/route.ts
index 252c378..d8124d9 100644
--- a/app/api/device-actions/uninstall/route.ts
+++ b/app/api/device-actions/uninstall/route.ts
@@ -6,16 +6,21 @@
* `run_cfgutil_remove_app`. This endpoint:
*
* 1. Re-runs the gate check server-side (audience + flag + backup
- * freshness) so a malicious page can't bypass the webview's
- * gating by hand-crafting an invoke. Returns 403 with a
- * structured `{ reason }` body when refused so the wizard can
- * render the right copy.
+ * freshness) before writing any audit row, so a hand-crafted API
+ * call can't stamp legitimate-looking rows for a gated-off
+ * configuration. Returns 403 with a structured `{ reason }` body
+ * when refused so the wizard can render the right copy. Note the
+ * limits of this check: the destructive call itself is a Tauri
+ * command that never passes through this server — the control
+ * that actually stops a compromised webview is the native
+ * Touch ID prompt inside `run_cfgutil_remove_app`.
* 2. Writes a `cfgutil_uninstall` activity row regardless of
* success — failures are as important to log as successes.
*
* The endpoint is GET-able too: `GET /api/device-actions/uninstall?ecid=…`
* returns the gate result without committing anything. The wizard
- * uses this to decide whether to render the uninstall buttons at all.
+ * calls this as a fail-closed pre-flight at the top of its bulk
+ * uninstall loop, before the first removal fires.
*/
import { type NextRequest, NextResponse } from "next/server";
diff --git a/app/components/ReviewRecommendationsView.tsx b/app/components/ReviewRecommendationsView.tsx
index 98b5574..c059a7a 100644
--- a/app/components/ReviewRecommendationsView.tsx
+++ b/app/components/ReviewRecommendationsView.tsx
@@ -121,6 +121,19 @@ interface Props {
*/
type Step = "review" | "compare" | "action" | "backup" | "act";
+/**
+ * Maps `DeviceActionGate` denial reasons (see lib/device-actions.ts) to
+ * `review_rec.act.*` message keys for the pre-flight banner. Unknown
+ * reasons fall back to `gate_denied_generic` in the caller — fail
+ * closed with an honest "refused" message rather than guessing.
+ */
+const GATE_DENIAL_KEYS: Record = {
+ audience: "gate_denied_audience",
+ backup_missing: "gate_denied_backup",
+ backup_stale: "gate_denied_backup",
+ flag: "gate_denied_flag",
+};
+
interface BackupState {
device: ConnectedDevice | null;
error: string | null;
@@ -223,6 +236,14 @@ export default function ReviewRecommendationsView({
const [uninstallStates, setUninstallStates] = useState<
Record
>({});
+ /**
+ * True when the backup itself succeeded but persisting its stamp
+ * (POST /api/device-actions/backup) failed. The act step's pre-flight
+ * gate reads the server-side stamp, so an unrecorded backup will be
+ * treated as missing — this flag surfaces that mismatch on the
+ * backup step instead of letting the act step refuse "mysteriously".
+ */
+ const [backupRecordFailed, setBackupRecordFailed] = useState(false);
/**
* Per-row free-text "replacing with" memo — captured during the
* Compare step. Stored in component state only (not persisted) so
@@ -368,6 +389,20 @@ export default function ReviewRecommendationsView({
null | "list" | "final" | "executing"
>(null);
const [bulkConfirmText, setBulkConfirmText] = useState("");
+ /**
+ * Set when the pre-flight gate check (GET /api/device-actions/
+ * uninstall) refuses the run or can't be reached. The bulk loop is
+ * aborted before any cfgutil call fires; this renders as an alert
+ * banner on the act step.
+ */
+ const [bulkGateError, setBulkGateError] = useState(null);
+ /**
+ * Count of activity-log writes (POST /api/device-actions/uninstall)
+ * that failed during the last bulk run. The removals themselves
+ * already ran — this only drives an "audit trail is incomplete"
+ * warning so the user knows the log can't be trusted for this run.
+ */
+ const [recordingFailures, setRecordingFailures] = useState(0);
// Modal focus management for the two bulk dialogs — declared here, after
// `bulkModal`, since the hook's `open` reads it.
const bulkListCardRef = useModalFocus({
@@ -616,6 +651,7 @@ export default function ReviewRecommendationsView({
return;
}
const device = devices.find((d) => d.ecid === selectedEcid) ?? null;
+ setBackupRecordFailed(false);
setBackup({
status: "running",
device,
@@ -635,8 +671,9 @@ export default function ReviewRecommendationsView({
});
return;
}
+ let recorded = false;
try {
- await fetch("/api/device-actions/backup", {
+ const res = await fetch("/api/device-actions/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -646,9 +683,17 @@ export default function ReviewRecommendationsView({
deviceName: device?.name ?? null,
}),
});
+ recorded = res.ok;
+ if (!res.ok) {
+ console.warn("[review] backup record refused:", res.status);
+ }
} catch (e) {
console.warn("[review] failed to record backup:", e);
}
+ // The backup itself succeeded — a failed recording downgrades to a
+ // warning, not a failed step. Without the stamp the act step's
+ // pre-flight will refuse the backed-up path, so tell the user now.
+ setBackupRecordFailed(!recorded);
setBackup({
status: "done",
device,
@@ -669,8 +714,9 @@ export default function ReviewRecommendationsView({
[row.id]: { status: "running", error: null },
}));
const result = await removeAppViaCfgutil(selectedEcid, row.bundleId);
+ let recorded = false;
try {
- await fetch("/api/device-actions/uninstall", {
+ const res = await fetch("/api/device-actions/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -683,9 +729,18 @@ export default function ReviewRecommendationsView({
acknowledgeNoBackup,
}),
});
+ recorded = res.ok;
+ if (!res.ok) {
+ console.warn("[review] uninstall record refused:", res.status);
+ }
} catch (e) {
console.warn("[review] failed to record uninstall outcome:", e);
}
+ if (!recorded) {
+ // The removal already ran — losing the audit row is a
+ // warning-level problem, surfaced once per bulk run.
+ setRecordingFailures((n) => n + 1);
+ }
setUninstallStates((prev) => ({
...prev,
[row.id]: result.ok
@@ -701,21 +756,54 @@ export default function ReviewRecommendationsView({
);
/**
- * Sequential bulk-uninstall runner. Iterates the queue, calling
- * `runUninstall` per row. Errors don't abort the batch — the per-app
- * state surfaces ✓/✕/spinner inline, and the user sees a summary
- * once the loop finishes. Apps that lack a bundle ID are skipped
- * (cfgutil needs one) and surface as `error`.
+ * Sequential bulk-uninstall runner. Pre-flights the server-side gate
+ * (audience + flag + backup freshness) BEFORE the first cfgutil call
+ * and aborts fail-closed when it refuses or can't be reached — the
+ * per-row POSTs after each removal only *record* outcomes, so this
+ * is the one point where the server can still stop the run. Then it
+ * iterates the queue, calling `runUninstall` per row. Errors don't
+ * abort the batch — the per-app state surfaces ✓/✕/spinner inline,
+ * and the user sees a summary once the loop finishes. Apps that lack
+ * a bundle ID are skipped (cfgutil needs one) and surface as `error`.
*
- * The `acknowledgeNoBackup` flag flows through to every per-app
- * request so the server-side gate can allow the bypass uniformly
- * across the batch.
+ * The `acknowledgeNoBackup` flag flows through the pre-flight and
+ * every per-app request so the server-side gate can allow the bypass
+ * uniformly across the batch.
*/
const runBulkUninstall = useCallback(
async (acknowledgeNoBackup: boolean) => {
if (!selectedEcid) {
return;
}
+ setBulkGateError(null);
+ setRecordingFailures(0);
+ try {
+ const qs = new URLSearchParams({ ecid: selectedEcid });
+ if (acknowledgeNoBackup) {
+ qs.set("acknowledgeNoBackup", "1");
+ }
+ const res = await fetch(`/api/device-actions/uninstall?${qs}`, {
+ cache: "no-store",
+ });
+ const gate = res.ok
+ ? ((await res.json()) as { allowed?: boolean; reason?: string })
+ : null;
+ if (gate?.allowed !== true) {
+ const key = gate
+ ? (GATE_DENIAL_KEYS[gate.reason ?? ""] ?? "gate_denied_generic")
+ : "gate_unreachable";
+ setBulkGateError(tAct(key));
+ setBulkModal(null);
+ setBulkConfirmText("");
+ return;
+ }
+ } catch (e) {
+ console.warn("[review] gate pre-flight failed:", e);
+ setBulkGateError(tAct("gate_unreachable"));
+ setBulkModal(null);
+ setBulkConfirmText("");
+ return;
+ }
setBulkModal("executing");
for (const row of uninstallQueue) {
if (!row.bundleId) {
@@ -1725,6 +1813,11 @@ export default function ReviewRecommendationsView({
})}
)}
+ {backup.status === "done" && backupRecordFailed && (
+
{backup.error}
@@ -1745,7 +1838,8 @@ export default function ReviewRecommendationsView({
on file so the user understands which Modal 2 variant
they'll see. Fresh ✓ → reassuring; missing / stale ⚠ →
warning. Drives no other behaviour here; the actual gate
- decision is made server-side when the bulk loop runs. */}
+ decision is the server-side pre-flight at the top of
+ runBulkUninstall, before any removal fires. */}
{uninstallQueue.length > 0 && (
)}
+ {/* Pre-flight refusal / audit-trail warnings. The gate error
+ means NOTHING was removed; the recording warning means
+ removals ran but one or more activity rows failed to
+ persist. Both are aria-live so screen-reader users hear
+ the outcome of a Delete click that didn't open the
+ executing state. */}
+ {bulkGateError && (
+
+ {bulkGateError}
+
+ )}
+ {recordingFailures > 0 && (
+
+ ⚠ {tAct("record_failures", { count: recordingFailures })}
+
+ )}
+
{uninstallQueue.length === 0 ? (
{tAct("empty")}
) : (
@@ -1878,6 +1989,7 @@ export default function ReviewRecommendationsView({
disabled={bulkModal === "executing"}
onClick={() => {
setBulkConfirmText("");
+ setBulkGateError(null);
setBulkModal("list");
}}
type="button"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..41233b3
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,295 @@
+# Architecture & workflows, end to end
+
+Every process the app runs — from typing an app name to deleting one off a plugged-in
+iPhone — drawn as flow diagrams across the runtimes, with known weak points marked where
+they live. Companion to the prose in [AGENTS.md](../AGENTS.md) and the hosted docs at
+[privacytracker-docs.privacykey.org](https://privacytracker-docs.privacykey.org/develop/architecture).
+
+**How to read the markers.** `⚠ §N·M` = open finding, `✅ §N·M` = fixed. Every marker is a
+row in the [improvement backlog](#7--improvement-backlog) at the bottom. When you fix one,
+update its row and the diagram label in the same PR.
+
+*Findings audited against source on 2026-07-06 (branch `feat/eager-shannon-b87c90`).*
+
+---
+
+## 0 · System map
+
+The desktop app is a Tauri shell that boots a private Next.js server (the "sidecar") on a
+random localhost port, then points its webview at it. Everything privacy-critical happens
+on this machine: scraping, diffing, AI calls, and the SQLite database. The Rust shell is
+the only piece that can touch a connected iPhone. The web/Docker build is the same server
+without the shell column.
+
+```mermaid
+flowchart LR
+ subgraph mac["This Mac · Tauri desktop app"]
+ shell["Rust shell (src-tauri/)
tray · deep links · usb_watcher
cfgutil bridge · Touch ID gate
ACL: 14 allowed commands"]
+ webview["Webview (Next.js UI)
dashboard · onboarding wizard
review-and-act wizard · TaskCenter"]
+ sidecar["Node sidecar (Next server)
app/api/* routes → lib/*
9 boot timers · 3 bulk runners"]
+ db[("SQLite data/privacy.db
WAL · synchronous better-sqlite3
apps → types → categories")]
+ end
+ phone["iPhone / iPad over USB
list · installedApps · backup · remove-app"]
+ apple["Apple
iTunes Search API · App Store pages"]
+ archive["archive.org
availability · replay · Save Page Now"]
+ ai["AI provider (optional)
OpenAI / Anthropic / local"]
+
+ shell -->|"spawns · reveals window when /api/apps responds"| sidecar
+ webview <-->|"HTTP 127.0.0.1:<port>"| sidecar
+ webview -.->|"invoke() IPC · ACL + Touch ID"| shell
+ shell -.->|"cfgutil subprocess"| phone
+ sidecar --> db
+ sidecar -.-> apple
+ sidecar -.-> archive
+ sidecar -.-> ai
+```
+
+Boot handshake: `sidecar::boot()` binds `127.0.0.1:0` for a free port, spawns Node with
+`PORT`/`PRIVACYTRACKER_DATA_DIR`, polls `GET /api/apps` (≤60s), then navigates the webview
+and reveals the window (optionally behind a Touch ID unlock). Files:
+`src-tauri/src/main.rs`, `src-tauri/src/sidecar.rs`.
+
+---
+
+## 1 · Add & track an app (the core loop)
+
+The pipeline every other flow feeds into. A name becomes an App Store URL, the URL becomes
+parsed privacy labels, and every re-sync diffs against the previous snapshot to produce
+the change timeline and notifications. Re-syncs run the same path with `resync=true`.
+Files: `lib/scraper.ts`, `lib/changelog.ts`, `lib/privacy-policy.ts`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant UI as Webview UI
+ participant API as Sidecar API
+ participant LIB as lib/ pipeline
+ participant DB as SQLite
+ participant EXT as Apple / AI
+
+ UI->>API: POST /api/search — names · bundleIds · country
+ API->>EXT: iTunes Search API
+ EXT-->>UI: candidates — user picks the right match
+ UI->>API: POST /api/scrape — urls · resync flag
+ API->>LIB: fetchAndParseApp(url)
+ LIB->>EXT: GET App Store page HTML
+ Note over LIB: ⚠ §1·1 parse fallback chain
shelfMapping → privacyHeader → shelves → shoebox
+ LIB->>LIB: capture previousSnapshot BEFORE the write
+ LIB->>DB: saveToDb tx — apps → privacy_types → privacy_categories
+ LIB->>DB: buildSnapshot → diffSnapshots → saveSnapshot
+ DB-->>DB: notification row · changeCount bump
+ Note over LIB: ⚠ §1·2 policy fetch + hash —
regenerate summary_json only when hash changed
+ LIB->>EXT: AI summarisation (optional, chunked for local models)
+ DB-->>UI: timeline · bell · pending-changes dot
+```
+
+---
+
+## 2 · Import your apps from a device (cfgutil)
+
+Onboarding (`OnboardWizard.tsx`, five steps: choose method → import & reconcile → confirm
+matches → import progress → policy summaries) accepts four sources: screenshots (OCR),
+CSV/TXT upload, manual typing, and — on macOS with Apple Configurator — a live `cfgutil`
+export. The cfgutil path is **read-only against the device**. There is no scheduled device
+re-sync: a USB plug-in event (IOKit watcher → `DeviceConnectedToast`) re-opens this flow
+on demand. Files: `src-tauri/src/cfgutil.rs`, `lib/desktop.ts`, `src-tauri/src/usb_watcher.rs`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WIZ as Onboard wizard
+ participant SH as Rust shell
+ participant PH as iPhone (USB)
+ participant API as Sidecar API
+ participant AP as Apple
+
+ WIZ->>SH: invoke check_cfgutil — PATH + app-bundle probes, cached 5 min
+ WIZ->>SH: poll list_connected_devices (5s while on the step)
+ SH->>PH: cfgutil list · get name/model
+ PH-->>WIZ: devices (ECID · name · iOS version)
+ WIZ->>SH: invoke run_cfgutil_export(ecid)
+ SH->>PH: cfgutil get installedApps (90s cap, read-only)
+ PH-->>WIZ: rows — displayName · bundleIdentifier · version
+ WIZ->>WIZ: step 2 — dedupe by bundleId, diff-preview
+ WIZ->>API: POST /api/search with bundleIds
+ API->>AP: iTunes lookup — canonical track per bundle id
+ WIZ->>WIZ: step 3 — name-search fallback for unlisted/sideloaded
+ Note over API: ⚠ §2·1 step 4 — scrape each URL (§1 flow) ·
Apple 429s park rows in the import queue (60s drain)
+ WIZ->>API: step 5 — optional policy summaries (flag-gated)
+```
+
+Non-Mac alternative: `scripts/ios-app-import/export_ios_apps.py` (stdlib-only) produces a
+`.txt`/`.csv` the user feeds back into the CSV path (⚠ §2·2 — manual round-trip).
+
+---
+
+## 3 · Back up, then delete apps off the phone
+
+The only destructive flow in the product, so it runs the deepest gate stack: audience must
+be `self`, an off-by-default Developer Options flag, a fresh backup (≤24h) or an explicit
+typed acknowledgement, two confirm modals, a server-side pre-flight, and finally a native
+Touch ID prompt per app that JavaScript cannot bypass. One cfgutil call per app — there is
+deliberately no batch primitive. Files: `app/components/ReviewRecommendationsView.tsx`,
+`lib/device-actions.ts`, `app/api/device-actions/*`, `src-tauri/src/cfgutil.rs`,
+`src-tauri/src/touch_id.rs`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WIZ as Review wizard
+ participant API as Sidecar (gates + audit)
+ participant SH as Rust shell
+ participant PH as iPhone (USB)
+
+ Note over WIZ: ◆ entry gate — audience=self ∧ flag on ∧ desktop build
+ WIZ->>WIZ: steps 1–3 — own "uninstall" verdicts only ·
imported recommendations never execute
+ WIZ->>WIZ: step 4 — pick device · warn when ECID ≠ app's source device
+ WIZ->>SH: invoke run_cfgutil_backup(ecid, destDir)
+ Note over SH: ⚠ §3·1 --backup-output unverified on real cfgutil
⚠ §3·2 success = exit code only, no on-disk check
⚠ §3·4 dest allowlist is lexical (symlinks not resolved)
+ SH->>PH: cfgutil backup (300s ceiling)
+ WIZ->>API: POST /api/device-actions/backup
+ Note over API: ✅ §3·6 normalizeEcid (0x-prefixed ECIDs) → stamp + activity row
+ WIZ->>WIZ: step 5 — "Delete N apps" → modal 1 (list) → modal 2 (type DELETE)
+ Note over WIZ: ⚠ §3·5 modal variant keys off session-local backup state
+ WIZ->>API: GET gate pre-flight — audience ∧ flag ∧ backup ≤24h (or acknowledged)
+ Note over API: ✅ §3·7 pre-flight BEFORE first removal, fail closed
+ loop one app at a time
+ WIZ->>SH: invoke run_cfgutil_remove_app(ecid, bundleId)
+ Note over SH: ◆ Touch ID / password per app — native LAContext,
JS cannot bypass · fails closed without biometrics+password
+ SH->>PH: cfgutil remove-app (45s timeout)
+ Note over PH: ⚠ §3·3 timed-out child is orphaned, not killed —
removal may still complete after a reported failure
+ WIZ->>API: POST record outcome — cfgutil_uninstall row (ok/error + ack flag)
+ end
+```
+
+---
+
+## 4 · Background jobs & crash-safe resume
+
+Server boot (`instrumentation.ts`) arms nine timers. Three bulk runners (App Store sync,
+Wayback import, policy sync) share one crash-safety pattern: a mutex key plus a state blob
+in `app_settings`, rewritten at every app boundary — a process kill loses at most one
+app's work, and boot-time healers resume or clear what's left.
+
+| When | What | Then every |
+| --- | --- | --- |
+| t=0 | watchdog · error ring · diagnostics · feature-flag migration · clear `import_queue_running`/`health_check_running` stale locks | — |
+| +8s / +10s / +12s | resume healers: wayback → sync → policy (resume pending run, or clear a stale mutex) | on boot |
+| +15s | scheduler tick — `getSchedulerStatus().isDue` (daily/weekly/manual) → `runBulkSync` | 30 min |
+| +20s | import-queue drain (rows parked by onboarding 429s) | 60 s |
+| +25s | update check (GitHub, 24h response cache) | 6 h |
+| +35s | whole-DB backup snapshots (`lib/backup.ts`, signed JSON — distinct from device backups) | 30 min |
+| +60s | health check — PASSIVE WAL checkpoint, clear provably-dead locks, report-only memory/orphan checks | 24 h |
+
+The resume healers are staggered *before* the 60s health check so a freshly-resumed run is
+never mistaken for a dead lock.
+
+```mermaid
+flowchart TD
+ acquire["◆ acquire mutex
sync_running · wayback_import_running · policy_sync_running"]
+ blob["state blob in app_settings
runId · queue · totals · initiator"]
+ work["process app N
blob rewritten at every app boundary"]
+ r429["429 from Apple (sync)
bail + partial activity row
clear state cleanly"]
+ done["clean finish
clear blob + mutex · summary row"]
+ crash["process killed mid-run
blob + mutex survive on disk"]
+ heal["◆ boot resume healers (+8/10/12s)
resume run · or clear stale lock"]
+ resume["runner restarts, initiator: resume
per-target dedup skips done work"]
+ ui["TaskCenter polls /api/tasks/active every 4s
resumed-run pill · per-job progress GETs"]
+
+ acquire --> blob --> work
+ work -->|"⚠ §4·1 whole run restarts next tick"| r429
+ work -->|all apps done| done
+ work -.->|kill -9 / power loss| crash
+ crash --> heal --> resume --> work
+ resume -.->|"⚠ §4·2 three separate pollers"| ui
+```
+
+Apple 429 handling is deliberate: an expected, recoverable condition clears state cleanly
+(unlike a crash) so the next 30-minute tick retries fresh.
+
+---
+
+## 5 · Wayback: back-filling label history to 2021
+
+Reconstructs an app's privacy-label history from archive.org — one target per quarter back
+to Q1 2021 plus an "install anchor" at `apps.firstSeen`, so the since-install diff has a
+real baseline. Read-only against the archive except one Save-Page-Now request per app when
+a quarter has no usable capture. Files: `lib/historical-import.ts`, `lib/wayback-bulk-runner.ts`.
+
+```mermaid
+flowchart TD
+ entry["per-app or bulk entry
POST import-history · import-all (NDJSON stream)"]
+ targets["computeHistoricalTargets
quarters to 2021-Q1 + anchor at firstSeen"]
+ avail["archive.org availability API
closest capture per target"]
+ walk{"◆ tolerance walk
±14/28/42d probes
drop if >45d drift"}
+ spn["⚠ §5·1 no capture anywhere →
Save-Page-Now for the live page
once per app per run"]
+ fetch["fetch replay (id_ URL)
clean original HTML"]
+ parse["parse — shoebox extractor for old
Ember pages · modern chain for 2025+"]
+ pipe["same §1 pipeline
source='wayback' · backdated scrapedAt
no changeCount bump"]
+ tl["timeline: purple wayback rows
'Matches live sync' badge · since-install baseline"]
+
+ entry --> targets --> avail --> walk
+ walk -->|miss| spn
+ walk -->|hit| fetch --> parse --> pipe --> tl
+```
+
+---
+
+## 6 · The gate chain every surface answers to
+
+Whether any card, step, or destructive action exists at all is resolved through one
+layered chain — later stages override earlier ones, user override always wins. The §3 flow
+adds two hard gates on top (backup freshness, Touch ID) that no flag can soften.
+
+```mermaid
+flowchart LR
+ hd["hard default
HARD_DEFAULTS"] --> aud["audience
self / loved_one / guardian"]
+ aud --> goal["goals
monitor · cleanup · minimal"]
+ goal --> a11y["accessibility
modifier bundle"]
+ a11y --> rt["runtime
Tauri-only off on web"]
+ rt --> dep["dependency
flags requiring flags"]
+ dep --> ovr(["user override — final word"])
+```
+
+Kill-switch: `flag.devopts.feature_flag_system.enabled=off` collapses everything to hard
+defaults without a code rollback. The delete flow's `flag.devopts.cfgutil_uninstall`
+defaults to **off**, and the audience gate is enforced in code — flipping the flag on
+under `guardian` still shows nothing. Modules: `lib/feature-flag-rules.ts`,
+`lib/feature-flags*.ts` (see AGENTS.md for the five-module split).
+
+---
+
+## 7 · Improvement backlog
+
+Point-in-time (2026-07-06). Refs match the `⚠`/`✅` markers in the diagrams above. Prune or
+flip rows as they land, and update the diagram label in the same PR.
+
+| Ref | Area | Status | Finding → candidate improvement | Effort |
+| --- | --- | --- | --- | --- |
+| §3·1 | Device backup | **open · high** | `cfgutil backup --backup-output` appears in no public cfgutil docs (canonical: `backup` takes no options, writes to MobileSync). Verify `cfgutil help backup` on a Mac with Configurator; if rejected, run plain `backup` and resolve the real path via `list-backups`. | S–M |
+| §3·2 | Device backup | **open · high** | Backup success is exit-code only. Verify on disk (dir non-empty / `Manifest.db`) before stamping; never record a fallback path that wasn't observed. | S |
+| §3·3 | Device delete | open | `run_with_timeout` orphans the cfgutil child on timeout — "failed" can silently become "succeeded later". Kill the process group, or re-check installed state after timeout and correct the audit row. | S |
+| §3·4 | Device backup | open | `resolve_backup_dest` claims symlink protection but checks lexically. Canonicalise the existing ancestor, or fix the comment. | S |
+| §3·5 | Delete UX / audit | open | Final modal's backup variant + acknowledge flag key off session-local state. Drive both from the server stamp (GET gate) so audit rows stop over-reporting "no backup acknowledged". Spec ready: [docs/specs/3-5-server-stamp-backup-variant.md](specs/3-5-server-stamp-backup-variant.md). | S |
+| §3·6 | Device actions | ✅ fixed | ECID normalisation (`0x`-prefixed) across stamp store, gate, and routes; pinned by tests with real-format ECIDs. | — |
+| §3·7 | Device actions | ✅ fixed | Server gate pre-flights before the first removal (fail closed); recording failures surface in the UI. | — |
+| §1·1 | Scraper | open | No parser canary. Add fixture tests against recorded App Store HTML + an alert/activity row when a scrape parses zero privacy types for an app that previously had them. | M |
+| §1·2 | AI summaries | idea | Summarisation silently degrades without a provider; chunking for local models is heuristic. Consider a visible "summary stale/unavailable" state. | S |
+| §2·1 / §4·1 | Rate limiting | open | On 429 the bulk sync abandons the run and restarts the whole fleet next tick. Resume from the state blob's cursor instead; consider shared per-app backoff with the import queue. | M |
+| §2·2 | Cross-platform import | idea | Python export needs a manual round-trip. Drag-drop hint or watch-folder hand-off. | M |
+| §4·2 | Polling | idea | Three pollers (TaskCenter 4s, notification watcher, per-job GETs) → one SSE stream from the sidecar. | L |
+| §5·1 | Wayback | idea | Track quarters skipped for lack of captures and offer "retry skipped" once Save-Page-Now requests have had time to land. | S–M |
+
+Suggested order: §3·1 and §3·2 first (they decide whether "we back up before deleting" is
+true at all), then §1·1 (protects the core product), then §3·3/§3·4/§3·5 as one small
+hardening PR, then the rate-limit resume (§2·1/§4·1).
+
+---
+
+## Keeping this document honest
+
+- Diagrams are Mermaid — edit them in place; GitHub renders them natively.
+- The backlog is point-in-time by design. When a finding is fixed, flip its row to ✅ and
+ update the matching `⚠` label in the diagram in the same PR (or delete both once stale).
+- Timings (boot delays, poll intervals, timeouts) were read from source on the date above;
+ if you change one in code, grep this file for the old value.
diff --git a/docs/specs/3-5-server-stamp-backup-variant.md b/docs/specs/3-5-server-stamp-backup-variant.md
new file mode 100644
index 0000000..eda129a
--- /dev/null
+++ b/docs/specs/3-5-server-stamp-backup-variant.md
@@ -0,0 +1,168 @@
+# Spec §3·5 — Drive the delete-confirm modal's backup state from the server stamp
+
+**Backlog ref:** §3·5 in [docs/ARCHITECTURE.md](../ARCHITECTURE.md#7--improvement-backlog) ·
+**Size:** S (~150–250 LOC including tests) · **Area:** device actions (TypeScript only — no Rust)
+· **Good first issue:** yes — single component + one new pure helper + tests, no hardware needed.
+
+Read [AGENTS.md](../../AGENTS.md) first (commands, conventions), and skim
+[docs/ARCHITECTURE.md §3](../ARCHITECTURE.md#3--back-up-then-delete-apps-off-the-phone)
+for the flow this touches.
+
+---
+
+## Background
+
+The review-and-act wizard (`app/components/ReviewRecommendationsView.tsx`) deletes apps off
+a connected iPhone via cfgutil. Before running the bulk delete it shows two modals; the
+second ("type DELETE") has two copy variants:
+
+- **fresh-backup variant** — reassuring, shows device name + backup time;
+- **no-backup variant** — louder "at your own risk" wording.
+
+Which variant renders — and whether the per-app recording POSTs carry
+`acknowledgeNoBackup: true` — is currently decided by **session-local component state**:
+
+```ts
+// ReviewRecommendationsView.tsx — Modal 2 confirm button
+const acknowledgeNoBackup = backup.status !== "done";
+```
+
+`backup.status` only becomes `"done"` when a backup ran **in this mount of the wizard**.
+The server, meanwhile, keeps its own durable stamp per device
+(`cfgutil_last_backup_
` in `app_settings`, written by
+`POST /api/device-actions/backup`, read by `checkUninstallGate()` in
+`lib/device-actions.ts` with a 24h freshness window, `BACKUP_FRESHNESS_WINDOW_MS`).
+
+The two sources of truth disagree in real scenarios:
+
+1. **Backed up 2 hours ago, then reopened the wizard.** Server stamp is fresh; local state
+ is `idle`. The user sees the scary no-backup modal and the audit log records
+ `acknowledgedNoBackup: true` — over-reporting risk they didn't actually take.
+2. **Backup succeeded but its recording POST failed.** Local state says `done`; the server
+ has no stamp. The modal shows the reassuring variant, then the pre-flight gate
+ (`GET /api/device-actions/uninstall`) refuses `backup_missing` — correct but confusing,
+ because the modal just promised a backup existed.
+
+The fix: make the **server stamp** the single source of truth for the modal variant, the
+act-step banner, and the `acknowledgeNoBackup` flag. Session-local `backup.status` remains
+the source of truth only for the backup step's own progress UI (running / done / error).
+
+## Current behaviour — where to look
+
+All in `app/components/ReviewRecommendationsView.tsx` (find by searching for the quoted
+strings; line numbers drift):
+
+| Spot | Search for | Current driver |
+| --- | --- | --- |
+| Act-step banner (✓ fresh / ⚠ missing) | `review-rec-backup-status` | `backup.status === "done"` |
+| Modal 2 copy variant | `bulk-final-title` | `backup.status === "done" && backup.finishedAt` |
+| Acknowledge flag | `backup.status !== "done"` | local state |
+| Pre-flight gate (do NOT change) | `gate pre-flight` in `runBulkUninstall` | server GET, fail closed |
+
+Server side:
+
+- `GET /api/device-actions/uninstall?ecid=…` (`app/api/device-actions/uninstall/route.ts`)
+ returns `checkUninstallGate(ecid)`: `{ allowed: true }` or
+ `{ allowed: false, reason: "audience" | "flag" | "backup_missing" | "backup_stale", … }`.
+- `getLastBackup(ecid)` (`lib/device-actions.ts`) returns
+ `{ finishedAt: number, path: string } | null`. ECIDs are normalised (`normalizeEcid`)
+ so cfgutil's `0x…` spellings round-trip.
+
+## Desired behaviour
+
+1. **Extend the GET response (additive).** `GET /api/device-actions/uninstall?ecid=…`
+ additionally returns `lastBackup: { finishedAt, path } | null` alongside the existing
+ gate fields. Additive only — existing fields and the POST contract must not change.
+
+2. **New pure helper + shared types.** Create `lib/device-actions-shared.ts` (no
+ `"server-only"` import — the existing server module `lib/device-actions.ts` cannot be
+ imported from client components; follow the `lib/changelog-types.ts` precedent for
+ client-safe shared types). Export:
+
+ ```ts
+ export type UninstallGateResponse = {
+ allowed?: boolean;
+ reason?: "audience" | "flag" | "backup_missing" | "backup_stale";
+ lastBackup?: { finishedAt: number; path: string } | null;
+ };
+
+ export type ServerBackupState =
+ | { kind: "fresh"; finishedAt: number }
+ | { kind: "not_fresh" }; // missing, stale, unreadable, or fetch failed
+
+ export function deriveServerBackupState(
+ gate: UninstallGateResponse | null
+ ): ServerBackupState;
+ ```
+
+ Rules: `allowed: true` **with** a `lastBackup` → `fresh` (use its `finishedAt`);
+ `allowed: true` **without** `lastBackup` (acknowledge path / unexpected) → `not_fresh`;
+ any denial, `null`, or malformed input → `not_fresh`. Never claim a backup exists that
+ the server didn't report — conservative by construction.
+
+3. **Wizard queries the stamp at the right moments.** In
+ `ReviewRecommendationsView.tsx`, fetch
+ `GET /api/device-actions/uninstall?ecid=` with `cache: "no-store"`:
+ - when the act step becomes active with a selected ECID (one fetch per entry, in a
+ `useEffect` keyed on `[step, selectedEcid]` — mirror the existing device-polling
+ effect's cancellation pattern);
+ - after `runBackup` completes successfully (so the banner flips to ✓ without a remount).
+ Store the derived `ServerBackupState` in component state. Do **not** fetch on every
+ render and do **not** touch the existing pre-flight inside `runBulkUninstall` — that
+ stays exactly as is (it is the execution gate; this work is display/audit semantics).
+
+4. **Three consumers switch to the derived state.**
+ - Act-step banner: ✓ variant when `fresh` (time from `finishedAt`), ⚠ otherwise.
+ - Modal 2: reassuring variant when `fresh` — reuse the existing
+ `confirm_modal.final_body` copy; when the local `backup.device` name is unknown
+ (cross-session case) the existing `fallback_device_name` string covers `{device}`.
+ No new i18n keys are expected; if you do add any, add them to **both**
+ `locales/en.json` and `locales/zh.json` and run `pnpm lint:i18n`.
+ - Confirm button: `const acknowledgeNoBackup = serverBackupState.kind !== "fresh";`
+ The backup step's own status text (running/done/error + saved-path line) stays on
+ local state.
+
+5. **Conservative on failure.** If the GET fails or returns junk, behave as `not_fresh`
+ (scary variant, `acknowledgeNoBackup: true`). This is deliberately the opposite
+ direction of the pre-flight (which fails **closed** by refusing) — here nothing is
+ blocked, we just refuse to *reassure*.
+
+## Tests (node:test — see `tests/app/uninstall-gate.test.ts` for style)
+
+New file `tests/app/device-actions-shared.test.ts` covering `deriveServerBackupState`:
+`allowed+lastBackup → fresh` (finishedAt passed through) · `allowed without lastBackup →
+not_fresh` · each denial reason → `not_fresh` · `null` / `{}` / garbage → `not_fresh`.
+
+Extend `tests/app/uninstall-gate.test.ts` (or a small new route-shaped test) to pin the
+additive GET payload: after `recordBackup(...)`, the route module's GET handler for that
+ECID includes `lastBackup.finishedAt`; for an unknown ECID `lastBackup` is `null`.
+
+## Acceptance criteria
+
+- [ ] `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm lint:i18n` all pass.
+- [ ] GET response is a strict superset of today's; POST semantics untouched.
+- [ ] `runBulkUninstall`'s pre-flight block is byte-identical (no behaviour change).
+- [ ] With a fresh server stamp and a **freshly mounted** wizard: banner shows ✓, Modal 2
+ shows the reassuring variant, recording POSTs carry `acknowledgeNoBackup: false`.
+- [ ] With no/stale stamp (even right after a local backup whose recording POST failed):
+ ⚠ banner, at-your-own-risk variant, `acknowledgeNoBackup: true`.
+- [ ] Helper lives in a client-safe module; no client import of `"server-only"` code
+ (this fails the build — see the five-module flag split in AGENTS.md for why).
+- [ ] No polling loops added; at most one gate fetch per act-step entry + one after a
+ completed backup.
+
+## Out of scope
+
+Backup verification on disk (§3·2), the `--backup-output` question (§3·1), Rust changes
+(§3·3/§3·4), batching or copy redesign, and anything touching `run_cfgutil_remove_app`.
+
+## Pitfalls
+
+- `lib/device-actions.ts` is `"server-only"` — importing it from the component is the
+ first thing that will fail the build. Hence the shared module.
+- Biome (`pnpm lint`) enforces repo style; `useExhaustiveDependencies` is off, and the
+ file uses eslint-disable-style comments for stable `t*` translators — mimic neighbours.
+- The wizard already has `bulkGateError` / pre-flight state — don't conflate it with the
+ new display state; they answer different questions ("may I run?" vs "should I reassure?").
+- `matches` in tests: use a real-format ECID (`0x9118908BB6027`) at least once — the
+ normalisation path is load-bearing (see tests already pinning it).
diff --git a/lib/device-actions.ts b/lib/device-actions.ts
index 82d0f36..f5d214a 100644
--- a/lib/device-actions.ts
+++ b/lib/device-actions.ts
@@ -30,16 +30,29 @@ export const BACKUP_FRESHNESS_WINDOW_MS = 24 * 60 * 60 * 1000;
const SETTINGS_BACKUP_PREFIX = "cfgutil_last_backup_";
/**
- * Apple ECIDs are hex strings (typically 12-20 chars). Validate at every
- * TS-side entry point even though the Rust command also char-allowlists,
- * so a stray caller can't synthesise a key like
- * `cfgutil_last_backup_flag.devopts.cfgutil_uninstall` via string
+ * Canonicalise an ECID for use in a settings key, or return null when
+ * the value isn't a plausible ECID.
+ *
+ * cfgutil prints ECIDs as `0x`-prefixed hex — its JSON `Output`
+ * dictionaries are keyed by strings like `0x9118908BB6027`, and that
+ * exact spelling is what the webview passes through from
+ * `list_connected_devices`. Strip the prefix and upper-case the hex
+ * body so a stamp written under any spelling (`0x9118908bb6027`,
+ * `9118908BB6027`, …) reads back under every other.
+ *
+ * Validation still matters even though the Rust commands also
+ * char-allowlist: a stray caller must not be able to synthesise a key
+ * like `cfgutil_last_backup_flag.devopts.cfgutil_uninstall` via string
* concatenation and collide with another setting key. Defence in depth
* — if the Rust validator changes or another TS entry point is added,
* this still keeps the namespace unambiguous.
*/
-function isValidEcid(value: string): boolean {
- return /^[A-Fa-f0-9]{8,24}$/.test(value);
+export function normalizeEcid(value: string): string | null {
+ const body = value.trim().replace(/^0[xX]/, "");
+ if (!/^[A-Fa-f0-9]{8,24}$/.test(body)) {
+ return null;
+ }
+ return body.toUpperCase();
}
interface BackupStamp {
@@ -120,10 +133,11 @@ export function checkUninstallGate(
/** Most recent backup stamp for the given ECID, or null. */
export function getLastBackup(ecid: string): BackupStamp | null {
- if (!isValidEcid(ecid)) {
+ const normalized = normalizeEcid(ecid);
+ if (!normalized) {
return null;
}
- const key = SETTINGS_BACKUP_PREFIX + ecid;
+ const key = SETTINGS_BACKUP_PREFIX + normalized;
const raw = getSetting(key, "");
if (!raw) {
return null;
@@ -149,10 +163,11 @@ export function recordBackup(opts: {
finishedAt: number;
deviceName: string | null;
}): void {
- if (!isValidEcid(opts.ecid)) {
+ const normalized = normalizeEcid(opts.ecid);
+ if (!normalized) {
throw new Error(`recordBackup: invalid ECID ${opts.ecid}`);
}
- const key = SETTINGS_BACKUP_PREFIX + opts.ecid;
+ const key = SETTINGS_BACKUP_PREFIX + normalized;
const stamp: BackupStamp = {
finishedAt: opts.finishedAt,
path: opts.path,
diff --git a/locales/en.json b/locales/en.json
index 2ad05e4..025e019 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -180,7 +180,8 @@
"skip_backup_title": "Continue without a backup. We'll ask you to type DELETE again before any app is removed.",
"mismatch_heading": "Heads up — this might not be the right device",
"mismatch_body": "{count, plural, one {# of the apps you're about to delete wasn't imported from this device. cfgutil will refuse to remove an app that isn't installed.} other {# of the apps you're about to delete weren't imported from this device. cfgutil will refuse to remove apps that aren't installed.}}",
- "mismatch_more": "…and {count} more"
+ "mismatch_more": "…and {count} more",
+ "record_failed": "The backup completed, but saving its record failed. The delete step won't see this backup and may refuse or ask for the no-backup confirmation."
},
"act": {
"heading": "Remove apps from your phone",
@@ -203,7 +204,13 @@
"backup_ok": "Backup of {device} taken at {time}",
"backup_missing_warn": "No recent backup of this device.",
"backup_missing_action": "Back up first",
- "default_device_name": "the connected device"
+ "default_device_name": "the connected device",
+ "gate_denied_audience": "The safety check refused this run, so nothing was deleted: app removal is only available when your focus is set to just you.",
+ "gate_denied_flag": "The safety check refused this run, so nothing was deleted: the \"Remove apps from your phone\" option is switched off in Developer Options.",
+ "gate_denied_backup": "The safety check refused this run, so nothing was deleted: it has no record of a fresh backup (under 24 hours old) for this device. Run a backup, then try again.",
+ "gate_denied_generic": "The safety check refused this run, so nothing was deleted.",
+ "gate_unreachable": "Couldn't reach the safety check, so nothing was deleted. Check that privacytracker's local service is running and try again.",
+ "record_failures": "{count, plural, one {# removal} other {# removals}} couldn't be written to the activity log — the apps were still processed, but the audit trail for this run is incomplete."
},
"saved_notes": {
"heading": "Your notes",
diff --git a/locales/zh.json b/locales/zh.json
index 79ac72d..b63ccd9 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -180,7 +180,8 @@
"skip_backup_title": "不备份直接继续。在任何应用被移除之前,我们仍会要求你再次输入 DELETE。",
"mismatch_heading": "提示 — 这可能不是正确的设备",
"mismatch_body": "{count, plural, one {你即将删除的应用中有 # 个不是从该设备导入的。cfgutil 不会移除未安装的应用。} other {你即将删除的应用中有 # 个不是从该设备导入的。cfgutil 不会移除未安装的应用。}}",
- "mismatch_more": "…还有 {count} 个"
+ "mismatch_more": "…还有 {count} 个",
+ "record_failed": "备份已完成,但保存备份记录失败。删除步骤将看不到此备份,可能会拒绝执行或要求进行\"无备份\"确认。"
},
"act": {
"heading": "从手机移除应用",
@@ -203,7 +204,13 @@
"backup_ok": "已于 {time} 备份 {device}",
"backup_missing_warn": "该设备没有最近的备份。",
"backup_missing_action": "先备份",
- "default_device_name": "已连接的设备"
+ "default_device_name": "已连接的设备",
+ "gate_denied_audience": "安全检查拒绝了本次操作,未删除任何应用:只有当你的关注对象设置为\"自己\"时才能移除应用。",
+ "gate_denied_flag": "安全检查拒绝了本次操作,未删除任何应用:开发者选项中的\"从手机移除应用\"开关未开启。",
+ "gate_denied_backup": "安全检查拒绝了本次操作,未删除任何应用:没有该设备 24 小时内的备份记录。请先运行备份,然后重试。",
+ "gate_denied_generic": "安全检查拒绝了本次操作,未删除任何应用。",
+ "gate_unreachable": "无法连接安全检查服务,因此未删除任何应用。请确认 privacytracker 本地服务正在运行,然后重试。",
+ "record_failures": "{count, plural, one {# 项移除操作} other {# 项移除操作}}未能写入活动日志 — 应用仍已处理,但本次运行的审计记录不完整。"
},
"saved_notes": {
"heading": "你的备注",
diff --git a/tests/app/uninstall-gate.test.ts b/tests/app/uninstall-gate.test.ts
index 6266c80..9fd95a2 100644
--- a/tests/app/uninstall-gate.test.ts
+++ b/tests/app/uninstall-gate.test.ts
@@ -14,7 +14,12 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { checkUninstallGate } from "../../lib/device-actions";
+import {
+ checkUninstallGate,
+ getLastBackup,
+ normalizeEcid,
+ recordBackup,
+} from "../../lib/device-actions";
import {
createDevice,
getDeviceEcidsForApps,
@@ -113,6 +118,62 @@ test("a stale backup (>24h) is denied unless acknowledgeNoBackup overrides", ()
assert.equal(overrideGate.allowed, true);
});
+// ─── ECID normalisation ───────────────────────────────────────────
+// cfgutil keys its JSON `Output` by `0x`-prefixed hex ECIDs (e.g.
+// `0x9118908BB6027`), and the webview passes that spelling through
+// verbatim. The stamp store must accept it — and read back the same
+// stamp under any prefix/case spelling of the same ECID.
+
+const RAW_CFGUTIL_ECID = "0x9118908BB6027";
+
+test("recordBackup + gate accept cfgutil's 0x-prefixed ECIDs", () => {
+ beforeEach();
+ recordBackup({
+ ecid: RAW_CFGUTIL_ECID,
+ path: "/tmp/backup",
+ finishedAt: Date.now() - 60_000,
+ deviceName: "Test iPhone",
+ });
+ const gate = checkUninstallGate(RAW_CFGUTIL_ECID);
+ assert.equal(gate.allowed, true);
+});
+
+test("a stamp reads back regardless of 0x prefix or hex case", () => {
+ beforeEach();
+ recordBackup({
+ ecid: "0x9118908bb6027",
+ path: "/tmp/backup",
+ finishedAt: Date.now() - 60_000,
+ deviceName: null,
+ });
+ assert.notEqual(getLastBackup("9118908BB6027"), null);
+ assert.equal(checkUninstallGate("0X9118908BB6027").allowed, true);
+});
+
+test("normalizeEcid canonicalises valid spellings and rejects the rest", () => {
+ assert.equal(normalizeEcid("0x9118908BB6027"), "9118908BB6027");
+ assert.equal(normalizeEcid("0X9118908bb6027"), "9118908BB6027");
+ assert.equal(normalizeEcid(" ABCDEF1234567890 "), "ABCDEF1234567890");
+ // Settings-key injection shapes and non-hex garbage must not pass.
+ assert.equal(normalizeEcid("flag.devopts.cfgutil_uninstall"), null);
+ assert.equal(normalizeEcid("0x"), null);
+ assert.equal(normalizeEcid(""), null);
+ assert.equal(normalizeEcid("zzzz11112222"), null);
+ assert.equal(normalizeEcid("0x123"), null); // hex body too short
+});
+
+test("recordBackup throws on a malformed ECID", () => {
+ beforeEach();
+ assert.throws(() =>
+ recordBackup({
+ ecid: "not-an-ecid",
+ path: "/tmp/backup",
+ finishedAt: Date.now(),
+ deviceName: null,
+ })
+ );
+});
+
// ─── getDeviceEcidsForApps ────────────────────────────────────────
test("getDeviceEcidsForApps returns ECIDs only for apps with cfgutil-imported devices", () => {