Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions app/api/device-actions/backup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
Expand Down
15 changes: 10 additions & 5 deletions app/api/device-actions/uninstall/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
134 changes: 123 additions & 11 deletions app/components/ReviewRecommendationsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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;
Expand Down Expand Up @@ -223,6 +236,14 @@ export default function ReviewRecommendationsView({
const [uninstallStates, setUninstallStates] = useState<
Record<string, UninstallState>
>({});
/**
* 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
Expand Down Expand Up @@ -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<string | null>(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<HTMLDivElement>({
Expand Down Expand Up @@ -616,6 +651,7 @@ export default function ReviewRecommendationsView({
return;
}
const device = devices.find((d) => d.ecid === selectedEcid) ?? null;
setBackupRecordFailed(false);
setBackup({
status: "running",
device,
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -1725,6 +1813,11 @@ export default function ReviewRecommendationsView({
})}
</p>
)}
{backup.status === "done" && backupRecordFailed && (
<p className="review-rec-error" role="alert">
⚠ {tBackup("record_failed")}
</p>
)}
{backup.status === "error" && (
<p className="review-rec-error" role="alert">
{backup.error}
Expand All @@ -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 && (
<div
className={`review-rec-backup-status${
Expand Down Expand Up @@ -1778,6 +1872,23 @@ export default function ReviewRecommendationsView({
</div>
)}

{/* 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 && (
<p className="review-rec-error" role="alert">
{bulkGateError}
</p>
)}
{recordingFailures > 0 && (
<p className="review-rec-error" role="alert">
⚠ {tAct("record_failures", { count: recordingFailures })}
</p>
)}

{uninstallQueue.length === 0 ? (
<p className="review-rec-step-sub">{tAct("empty")}</p>
) : (
Expand Down Expand Up @@ -1878,6 +1989,7 @@ export default function ReviewRecommendationsView({
disabled={bulkModal === "executing"}
onClick={() => {
setBulkConfirmText("");
setBulkGateError(null);
setBulkModal("list");
}}
type="button"
Expand Down
Loading
Loading