diff --git a/api-reference/introduction.mdx b/api-reference/introduction.mdx
index a49bba1..ee38f12 100644
--- a/api-reference/introduction.mdx
+++ b/api-reference/introduction.mdx
@@ -17,16 +17,25 @@ Self-hosted users should substitute their own base URL (and host header — see
## Authentication
-There are two layers, applied to *destructive* routes only. Read-only endpoints have neither.
+Three layers. Only the middle one is confined to mutations — reads pass the host allowlist like everything else, and on a network-exposed instance a list of sensitive read paths needs the admin token too.
-
- Every mutating verb (`POST`, `PUT`, `PATCH`, `DELETE`) checks that the `Origin` header matches the request host. Browser requests from your installed UI satisfy this automatically; cross-origin or curl-from-a-different-host calls won't.
+
+ Before any other check, a request whose effective `Host` isn't allowlisted is rejected — `GET` included:
- Failed CSRF returns:
+ ```json
+ { "error": "Host not allowed" }
+ ```
+
+ with status **400**. The allowlist defaults to loopback; operators extend it with `PRIVACYTRACKER_ALLOWED_HOSTS`. If your integration talks to the instance under a hostname the operator hasn't listed, every call fails here regardless of method or credentials.
+
+
+ Every mutating verb (`POST`, `PUT`, `PATCH`, `DELETE`) under `/api/` checks that the `Origin` header matches the request host, unless the request carries a valid admin token instead. Browser requests from your installed UI satisfy this automatically; cross-origin or curl-from-a-different-host calls won't.
+
+ Failed CSRF returns **403**:
```json
- { "error": "origin_mismatch" }
+ { "error": "Cross-origin mutation rejected" }
```
From `curl`, set the header explicitly:
@@ -39,7 +48,11 @@ There are two layers, applied to *destructive* routes only. Read-only endpoints
If you're behind a reverse proxy, the proxy must forward the original `Host` header. See [Troubleshooting → Reverse-proxy CSRF rejection](/troubleshooting#reverse-proxy-csrf-rejection).
- When `AUDITOR_ADMIN_TOKEN` is set in the environment, *destructive* routes (`POST /api/reset`, `DELETE /api/apps`, `POST /api/settings`, `DELETE /api/wayback/import-all`, etc.) require an `X-Auditor-Admin-Token` header on top of the CSRF check. Verification uses `crypto.timingSafeEqual`.
+ When `AUDITOR_ADMIN_TOKEN` is set in the environment, *destructive* routes (`POST /api/reset`, `DELETE /api/apps`, `POST /api/settings`, `DELETE /api/wayback/import-all`, etc.) require the token on top of the CSRF check. Verification uses `crypto.timingSafeEqual`. Missing or wrong token returns **401**:
+
+ ```json
+ { "error": "Admin token required" }
+ ```
Failed attempts are recorded in the `audit_log` table with requester IP + user agent.
@@ -49,7 +62,25 @@ There are two layers, applied to *destructive* routes only. Read-only endpoints
-H "X-Auditor-Admin-Token: $AUDITOR_ADMIN_TOKEN"
```
- The token is purely opt-in — there's no built-in user/account system in privacytracker. The intent is to gate destructive routes when the app is exposed beyond a single-user trusted boundary (e.g., self-hosted on a LAN).
+ **Reads are gated too on a network-exposed instance.** Once the operator sets `PRIVACYTRACKER_NETWORK_EXPOSED`, lists a non-loopback host, or binds to a specific non-loopback IP, `GET` requests under these prefixes also need the token:
+
+ ```
+ /api/ai/debug-log
+ /api/backup/
+ /api/deployment/
+ /api/desktop/diagnostics
+ /api/diagnostics/
+ /api/export
+ /api/import/
+ ```
+
+ Without it they return 401 `{ "error": "Admin token required for non-local API access" }`. This is the usual cause of a surprise 401 on `GET /api/backup/export` from a LAN integration.
+
+ Two of those reads don't wait for exposure. `GET /api/backup/export` and `GET /api/ai/debug-log` check `adminTokenConfigured() || isNetworkExposed()` in the handler itself, so they return 401 `{ "error": "Admin token required" }` as soon as `AUDITOR_ADMIN_TOKEN` is set — on a loopback-only install too.
+
+ **Cookie sessions.** `POST /api/auth/admin-token/login` with `{ "token": "..." }` exchanges the token for an 8-hour HttpOnly `pt_admin_token` cookie, which every gated route accepts in place of the header. `POST /api/auth/admin-token/logout` clears it; `GET /api/auth/admin-token/status` returns `{ configured, unlocked }`. Login and logout enforce same-origin themselves and login is capped at 5 attempts per minute; `status` is a read, so neither its handler nor the CSRF layer origin-checks it — it still has to clear the host allowlist, and it never returns the token. Prefer the header for scripted callers; the cookie exists so the browser UI never holds the raw secret in JavaScript.
+
+ The token is a single shared secret — there's no built-in user/account system in privacytracker. The intent is to gate destructive routes when the app is exposed beyond a single-user trusted boundary (e.g., self-hosted on a LAN).
@@ -59,23 +90,39 @@ A handful of patterns hold across every endpoint:
- **`apps.id` is Apple's numeric track ID**, extracted from `/id/` in the App Store URL — not a UUID. Snapshots, privacy rows, and notifications all key off it.
- **Timestamps are Unix milliseconds**, not seconds and not ISO-8601. JavaScript `Date.now()`-shaped.
-- **Errors are `{ "error": "", "details"?: "" }`** with the appropriate 4xx / 5xx status. Codes are stable; details are not.
+- **Errors are `{ "error": "" }`** with the appropriate 4xx / 5xx status. The message is human prose, not a stable machine code — switch on the status, not the string. A few routes add structured fields alongside it (`POST /api/backup/restore` sends `code: "untrusted_backup"` on a 409); those are documented per-endpoint.
- **Streamed responses** (`POST /api/wayback/import-all?stream=1`) emit NDJSON with a `kind` field per line — `batch-start`, `app-start`, `target`, `app-done`, `summary`.
- **Mutation routes returning 409 mean a mutex is held** by another in-flight run. Wait or check `GET /api/tasks/active` to see what's running.
## Rate limiting
-privacytracker doesn't impose its own rate limits — it's a single-user app. The rate limit you'll actually hit is **Apple's 429 on the iTunes Search API and `apps.apple.com`**. The bulk runners handle this gracefully:
+A 429 from privacytracker has two possible causes, and they need different handling.
+
+**privacytracker's own limiter.** Most routes are rate-limited per client IP. Some denials carry a `Retry-After` header in seconds — honour it when it's there, but don't depend on it. A clear majority of the 429 paths set it — 33 of the 56 rate-limited routes: all 23 behind the shared mutation guard, plus 10 direct callers such as `/api/scrape`, `/api/search`, and the token login. The rest return a bare 429 — including `POST /api/reset` and `POST /api/backup/restore` in the table below — so fall back to the route's own window when the header is absent. Representative limits:
+
+| Route | Limit |
+|---|---|
+| `POST /api/scrape` | 30 / minute |
+| `POST /api/search` | 60 / minute |
+| `POST /api/reset` | 30 / 10 minutes |
+| `POST /api/backup/restore` | 3 / 10 minutes |
+| `POST /api/auth/admin-token/login` | 5 / minute |
+
+Note that unless the operator sets `PRIVACYTRACKER_TRUST_PROXY`, forwarded-IP headers are ignored and every caller shares one bucket per route — so a busy sibling integration can consume your budget.
+
+**Apple's 429**, on the iTunes Search API and `apps.apple.com`, is the slower one. It surfaces through the bulk runners rather than as an HTTP status on your call:
- The runner bails out of its loop on the first 429.
- A `partial: rateLimited` activity row is written.
- State and mutex are cleared cleanly, so the next 30-minute scheduler tick can retry fresh.
-If your integration triggers scrapes directly via `POST /api/scrape`, expect 429 occasionally and back off for ~30 minutes when you see one.
+Tell them apart by where the 429 lands, not by `Retry-After` — the internal limiter sets that header on some routes only. An internal denial arrives as an HTTP 429 on the call you just made and clears within that route's own window (under a minute for `/api/scrape`). If you're seeing repeated `partial: rateLimited` in the activity log with no 429 on your own requests, that's Apple, and the useful response is to slow the schedule rather than retry.
## Backup bundle format
-`GET /api/backup/export` and `POST /api/backup/restore` use a versioned JSON envelope. The shape is documented in the spec; the canonical implementation lives in `lib/audit-bundle.ts`. Restore is forward-compatible — a v1.0 bundle restores cleanly into v1.1+, but not the reverse (newer bundles can carry fields older versions don't know how to migrate down).
+`GET /api/backup/export` and `POST /api/backup/restore` use a versioned JSON envelope carrying an integer `version` (currently `1`) — the bundle format version, not the app version. The canonical implementation is `lib/backup.ts`. A bundle from an older format restores into a newer release; a newer one is refused outright rather than misparsed.
+
+Envelopes are HMAC-signed with a key unique to the install that exported them. A bundle from a different install fails verification and gets **409** `{ "error": "...", "code": "untrusted_backup", "signaturePresent": true }`. To restore it anyway, pass `?allowUntrusted=1` or the header `x-allow-untrusted-backup: 1`. There is no `confirm` parameter — the `RESTORE` typing step is browser-side only.
Private annotations (`visibility = 'private'`) are unconditionally excluded from audit-bundle exports at the SQL level. There is no force-include path.
@@ -155,3 +202,4 @@ The OpenAPI spec covers the public-contract surface — the routes integrators m
| Bulk task status | `app/api/tasks/active/route.ts`, `app/api/{sync,wayback,policy}/**/route.ts` |
| Stats, charts | `app/api/stats/**/route.ts` |
| Health, deployment, admin | `app/api/{health,ready,deployment,admin,reset,dev}/**/route.ts` |
+| Admin-token sessions | `app/api/auth/admin-token/{login,logout,status}/route.ts` |
diff --git a/api-reference/openapi.yaml b/api-reference/openapi.yaml
index 497f6b5..76600fd 100644
--- a/api-reference/openapi.yaml
+++ b/api-reference/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: privacytracker API
- version: "1.1.0"
+ version: "0.1.2"
description: |
HTTP API for [privacytracker](https://github.com/privacykey/privacytracker) —
the routes under `app/api/**`. Each route is a thin wrapper over helpers in
@@ -41,7 +41,7 @@ tags:
- name: Annotations
description: Per-app freeform notes with tags and visibility.
- name: Verdicts
- description: Categorical per-app judgements (`tracking_concern`, `acceptable`, …).
+ description: Categorical per-app judgements (`safe`, `replace`, `uninstall`).
- name: Notifications
description: Bell-feed events and per-type notification preferences.
- name: Activity
@@ -88,45 +88,38 @@ components:
in: header
name: X-Auditor-Admin-Token
description: |
- Required when `AUDITOR_ADMIN_TOKEN` is set in the environment. Verified
- with `crypto.timingSafeEqual`. Failed attempts are recorded in
- `audit_log` with IP + user agent.
-
- schemas:
- ErrorCode:
- type: string
+ Required when `AUDITOR_ADMIN_TOKEN` is set in the environment, and — on a
+ network-exposed instance — on the sensitive read prefixes listed on the
+ [Overview page](/api-reference/introduction#authentication). Verified with
+ `crypto.timingSafeEqual`. Missing or wrong token returns 401. Failed
+ attempts are recorded in `audit_log` with IP + user agent.
+
+ Browser callers may send the `pt_admin_token` HttpOnly cookie instead;
+ it is accepted everywhere this header is. Obtain it from
+ `POST /api/auth/admin-token/login`.
+ adminTokenCookie:
+ type: apiKey
+ in: cookie
+ name: pt_admin_token
description: |
- Stable, machine-readable error code returned in `{ "error": "" }`.
- New codes may be added in minor releases; existing codes don't change
- meaning. Integrators should `switch` on this rather than parsing
- `details`, which is human-prose and not stable.
- enum:
- - origin_mismatch # CSRF check failed (Origin != Host)
- - admin_token_required # AUDITOR_ADMIN_TOKEN configured, header missing
- - admin_token_invalid # X-Auditor-Admin-Token didn't match (timingSafeEqual)
- - confirm_required # mutating route needs ?confirm=
- - already_running # mutex held by another in-flight run
- - rate_limited # Apple returned 429; runner backed off
- - not_found # resource doesn't exist (app id, version id, etc.)
- - invalid_request # malformed body or query
- - schema_mismatch # bundle version newer than app version
- - migration_failed # boot-time migration step failed
- - provider_unconfigured # AI provider needed but not set
- - provider_unreachable # AI provider didn't respond before timeout
- - upstream_unavailable # apps.apple.com / archive.org / iTunes Search unreachable
- - parser_failed # App Store HTML didn't match any known shape
- - quarantined # focus / override row references a removed feature flag
+ HttpOnly, `SameSite=strict`, 8-hour session cookie issued by
+ `POST /api/auth/admin-token/login`. Equivalent to the
+ `X-Auditor-Admin-Token` header on every gated route.
+ schemas:
Error:
type: object
required: [error]
+ description: |
+ Standard error envelope. `error` is a human-readable sentence, **not** a
+ stable machine code — branch on the HTTP status, not on the string.
+ A few routes add extra structured fields alongside it; those are
+ documented on the operation that returns them.
properties:
error:
- $ref: "#/components/schemas/ErrorCode"
- details:
type: string
- nullable: true
- description: Human-readable detail. Not stable across releases. Useful for logging, not for branching.
+ description: Human-readable message. Not stable across releases.
+ example: Admin token required
App:
type: object
@@ -346,11 +339,17 @@ components:
description: |
Versioned export of every app, label, snapshot, annotation,
notification, focus state, and feature-flag override. Restorable into
- the same or a later version of privacytracker.
+ any release that understands this bundle format or a later one.
+
+ Exported envelopes also carry a `signature` object (HMAC-SHA256 over
+ the canonicalised envelope, keyed per install). Restoring a bundle
+ whose signature doesn't match the target install requires
+ `allowUntrusted` — see `POST /api/backup/restore`.
properties:
version:
- type: string
- example: "1.1.0"
+ type: integer
+ description: Bundle format version — not the app version.
+ example: 1
exportedAt:
type: integer
format: int64
@@ -404,24 +403,26 @@ components:
HealthResponse:
type: object
- required: [ok]
+ required: [status]
properties:
- ok:
- type: boolean
- example: true
+ status:
+ type: string
+ enum: [ok, degraded]
+ example: ok
ReadyResponse:
type: object
- required: [ok, db, data]
+ required: [status, checks]
properties:
- ok:
- type: boolean
- db:
- type: string
- enum: [reachable, unreachable]
- data:
+ status:
type: string
- enum: [writable, "not writable"]
+ enum: [ready, not_ready]
+ checks:
+ type: object
+ additionalProperties: true
+ description: |
+ Per-check detail from the deployment diagnostics — which probe
+ failed and why. Shape is diagnostic output, not a stable contract.
ManualApp:
type: object
@@ -476,17 +477,36 @@ components:
Verdict:
type: object
- required: [appId, kind, updatedAt]
+ required: [appId, verdict, updatedAt]
+ description: |
+ A user's categorical judgement about one app. Stored in `app_verdicts`,
+ with a `CHECK` constraint pinning `verdict` to the three values below.
properties:
appId:
- type: integer
- format: int64
- kind:
type: string
- enum: [tracking_concern, acceptable, do_not_install]
- reason:
+ description: Apple's numeric track ID, as a string.
+ example: "324684580"
+ verdict:
+ type: string
+ enum: [safe, replace, uninstall]
+ rationale:
type: string
nullable: true
+ source:
+ type: string
+ enum: [user, imported]
+ description: |
+ `imported` rows come from an audit bundle, not from this endpoint.
+ readOnly: true
+ sourceName:
+ type: string
+ nullable: true
+ description: Recommender display name when `source` is `imported`; null otherwise.
+ readOnly: true
+ setAt:
+ type: integer
+ format: int64
+ readOnly: true
updatedAt:
type: integer
format: int64
@@ -529,28 +549,49 @@ components:
type: boolean
description: |
Map of notification type → enabled. Keys mirror the `Notification.kind`
- enum. Migrated in v1.1 from a single JSON blob in `app_settings`.
+ enum. Migrated from a single `notification_prefs` JSON blob in
+ `app_settings` by step 3 of the boot migration.
Focus:
type: object
- required: [audience, goals]
+ required: [audience]
+ description: |
+ Audience + goal booleans, flat — the goals are top-level fields, not a
+ nested object. `monitor` and `cleanup` were formerly `understand` and
+ `declutter`; the boot migration re-keys stored rows.
properties:
audience:
type: string
enum: [self, loved_one, guardian]
- goals:
- type: object
- properties:
- understand:
- type: boolean
- declutter:
- type: boolean
- minimal:
- type: boolean
- description: Mutually exclusive with `understand` and `declutter`.
- accessibility:
- type: boolean
- description: Modifier — combines with any primary goal.
+ monitor:
+ type: boolean
+ default: false
+ cleanup:
+ type: boolean
+ default: false
+ minimal:
+ type: boolean
+ default: false
+ description: Mutually exclusive with `monitor` and `cleanup`.
+ accessibility:
+ type: boolean
+ default: false
+ description: Modifier — combines with any primary goal.
+ workflow:
+ type: string
+ enum: [self_monitor, self_cleanup, other_handoff, other_monitor, custom]
+ description: |
+ Inferred from audience + goals when omitted on write, collapsing to
+ `custom` where the answer is ambiguous. `other_handoff` is what
+ unlocks audit-bundle export.
+ childAgeBand:
+ type: string
+ nullable: true
+ description: Guardian-only age band. Empty string or null clears it.
+ aiConfigured:
+ type: boolean
+ readOnly: true
+ description: Derived from `ai_provider`. Read-only.
FeatureFlagsSnapshot:
type: object
@@ -637,11 +678,127 @@ components:
additionalProperties: true
paths:
+ /api/auth/admin-token/login:
+ post:
+ tags: [Admin]
+ summary: Exchange the admin token for a session cookie
+ description: |
+ Constant-time compares the supplied token against `AUDITOR_ADMIN_TOKEN`
+ and, on a match, sets `pt_admin_token` — HttpOnly, `SameSite=strict`,
+ `Secure` over HTTPS, 8-hour max-age. Every route that accepts the
+ `X-Auditor-Admin-Token` header accepts this cookie instead.
+
+ Exempt from the non-local admin gate (it is how a caller obtains the
+ cookie), but same-origin is still required. Rate-limited to 5 attempts
+ per minute per client, with an IP-independent backstop of 100 failed
+ attempts per 15 minutes.
+ security:
+ - sameOrigin: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [token]
+ properties:
+ token:
+ type: string
+ responses:
+ "200":
+ description: Cookie set.
+ headers:
+ Set-Cookie:
+ schema:
+ type: string
+ example: pt_admin_token=…; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ok:
+ type: boolean
+ "400":
+ description: Body malformed or token missing.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "401":
+ description: Token did not match.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "403":
+ description: Same-origin check failed.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "429":
+ description: Per-client limit or the global brute-force backstop tripped.
+ headers:
+ Retry-After:
+ schema:
+ type: integer
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "503":
+ description: "`AUDITOR_ADMIN_TOKEN` is not configured on the server."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+
+ /api/auth/admin-token/logout:
+ post:
+ tags: [Admin]
+ summary: Clear the admin-token session cookie
+ description: |
+ Server-side counterpart to login — the cookie is HttpOnly, so JavaScript
+ cannot delete it. Same-origin required.
+ security:
+ - sameOrigin: []
+ responses:
+ "200":
+ description: Cookie cleared.
+ "403":
+ description: Same-origin check failed.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+
+ /api/auth/admin-token/status:
+ get:
+ tags: [Admin]
+ summary: Is an admin token configured, and is this caller unlocked?
+ description: Never returns the token itself.
+ responses:
+ "200":
+ description: Session state.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [configured, unlocked]
+ properties:
+ configured:
+ type: boolean
+ description: "`AUDITOR_ADMIN_TOKEN` is set on the server."
+ unlocked:
+ type: boolean
+ description: This request carried a valid cookie or header.
+
/api/health:
get:
tags: [Health]
summary: Liveness probe
- description: "Returns 200 with `{ ok: true }` whenever the process is responding. Used for cheap uptime checks."
+ description: "Returns 200 with `{ status: \"ok\" }` whenever the process is responding and a one-row SQLite ping succeeds; 503 with `{ status: \"degraded\" }` otherwise. Used for cheap uptime checks."
responses:
"200":
description: Process is alive.
@@ -649,15 +806,22 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HealthResponse"
+ "503":
+ description: DB ping failed.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HealthResponse"
/api/ready:
get:
tags: [Health]
summary: Readiness probe
description: |
- Returns 200 with `{ ok: true }` only when the database is reachable
- **and** the data directory is writable. Used by container healthchecks
- and reverse-proxy readiness gates.
+ Returns 200 with `{ status: "ready" }` only when the database is
+ reachable **and** the data directory is writable; otherwise 503 with
+ `{ status: "not_ready" }` and the failing entries in `checks`. Used by
+ container healthchecks and reverse-proxy readiness gates.
responses:
"200":
description: Healthy.
@@ -758,8 +922,14 @@ paths:
responses:
"200":
description: Deleted.
+ "401":
+ description: "Admin token required but missing or invalid."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
"403":
- description: CSRF or admin-token check failed.
+ description: "Same-origin CSRF check failed."
content:
application/json:
schema:
@@ -822,7 +992,16 @@ paths:
schema:
$ref: "#/components/schemas/ScrapeResult"
"429":
- description: Apple rate-limited the request.
+ description: |
+ Rate-limited. Two causes: privacytracker's own per-client limiter
+ (30 requests/minute on this route), or Apple rate-limiting the
+ upstream fetch. The internal limiter sets `Retry-After` and clears
+ within its window; Apple's cooldown is longer.
+ headers:
+ Retry-After:
+ description: Seconds to wait before retrying. Set by the internal limiter.
+ schema:
+ type: integer
content:
application/json:
schema:
@@ -1024,8 +1203,18 @@ paths:
summary: Export a versioned backup bundle
description: |
Returns a JSON bundle with every app, label, snapshot, annotation,
- notification, focus state, and feature-flag override. Versioned —
- restorable into the same or a later release.
+ notification, focus state, and feature-flag override. Signed with this
+ install's key, so it restores here without extra flags.
+
+ This read requires the admin token whenever `AUDITOR_ADMIN_TOKEN` is
+ set OR the instance is network-exposed. `/api/backup/` is one of the
+ proxy's gated read prefixes, and this handler additionally checks
+ `adminTokenConfigured() || isNetworkExposed()` — so a loopback-only
+ install with a token configured still gets a 401.
+ security:
+ - {}
+ - adminToken: []
+ - adminTokenCookie: []
responses:
"200":
description: JSON bundle.
@@ -1033,6 +1222,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BackupBundle"
+ "401":
+ description: "Admin token required (configured or network-exposed) and missing or invalid."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
/api/backup/preview:
post:
@@ -1071,18 +1266,28 @@ paths:
tags: [Backup]
summary: Restore a backup bundle
description: |
- Wipes the existing database and reloads from the bundle. Requires the
- typed-confirmation query parameter `confirm=RESTORE`.
+ Wipes the existing database and reloads from the bundle. There is no
+ `confirm` parameter — the typed `RESTORE` step exists only in the
+ browser UI, so an API call restores immediately.
+
+ Envelopes are HMAC-signed with a key unique to the exporting install.
+ A bundle from anywhere else fails verification and is rejected with
+ 409 unless `allowUntrusted` is set.
+
+ Rate-limited to 3 attempts per 10 minutes.
security:
- sameOrigin: []
adminToken: []
parameters:
- in: query
- name: confirm
- required: true
+ name: allowUntrusted
+ required: false
schema:
type: string
- enum: [RESTORE]
+ enum: ["1", "true"]
+ description: |
+ Restore a bundle whose signature doesn't match this install.
+ Equivalent to the `x-allow-untrusted-backup` header.
requestBody:
required: true
content:
@@ -1093,7 +1298,36 @@ paths:
"200":
description: Restore complete.
"400":
- description: Confirmation parameter missing or wrong value.
+ description: Bundle malformed or a newer format than this app supports.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "401":
+ description: "Admin token required but missing or invalid."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "409":
+ description: |
+ Either the bundle didn't originate on this install (see `code`), or
+ a sync is in flight.
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: "#/components/schemas/Error"
+ - type: object
+ properties:
+ code:
+ type: string
+ enum: [untrusted_backup]
+ signaturePresent:
+ type: boolean
+ description: False when the bundle carried no signature at all.
+ "429":
+ description: More than 3 restore attempts in 10 minutes.
content:
application/json:
schema:
@@ -1153,8 +1387,26 @@ paths:
responses:
"200":
description: Reset complete.
+ "401":
+ description: "Admin token required but missing or invalid."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
"403":
- description: CSRF or admin-token check failed.
+ description: "Same-origin CSRF check failed."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "409":
+ description: "A sync is currently running."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "429":
+ description: "More than 30 reset attempts in 10 minutes."
content:
application/json:
schema:
@@ -1171,8 +1423,14 @@ paths:
responses:
"200":
description: Start-over complete.
+ "401":
+ description: "Admin token required but missing or invalid."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
"403":
- description: CSRF or admin-token check failed.
+ description: "Same-origin CSRF check failed."
content:
application/json:
schema:
@@ -1553,8 +1811,7 @@ paths:
- in: query
name: appId
schema:
- type: integer
- format: int64
+ type: string
responses:
"200":
description: Verdict list.
@@ -1577,10 +1834,34 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/Verdict"
+ type: object
+ required: [appId, verdict]
+ properties:
+ appId:
+ type: string
+ example: "324684580"
+ verdict:
+ type: string
+ enum: [safe, replace, uninstall]
+ rationale:
+ type: string
+ nullable: true
responses:
- "200":
+ "201":
description: Verdict written.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ verdict:
+ $ref: "#/components/schemas/Verdict"
+ "400":
+ description: "Missing `appId`, or `verdict` outside the allowed set."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
delete:
tags: [Verdicts]
summary: Clear a verdict for an app
@@ -1591,8 +1872,7 @@ paths:
name: appId
required: true
schema:
- type: integer
- format: int64
+ type: string
responses:
"200":
description: Cleared.
@@ -1844,6 +2124,16 @@ paths:
get:
tags: [AI]
summary: Read AI debug log entries
+ description: |
+ This read requires the admin token whenever `AUDITOR_ADMIN_TOKEN` is
+ set OR the instance is network-exposed. `/api/ai/debug-log` is one of
+ the proxy's gated read prefixes, and this handler additionally checks
+ `adminTokenConfigured() || isNetworkExposed()` — so a loopback-only
+ install with a token configured still gets a 401.
+ security:
+ - {}
+ - adminToken: []
+ - adminTokenCookie: []
parameters:
- in: query
name: limit
@@ -2134,6 +2424,13 @@ paths:
get:
tags: [Import / export]
summary: Export apps + labels as CSV or JSON
+ description: |
+ On a network-exposed instance this read requires the admin token —
+ `/api/export` is one of the gated read prefixes.
+ security:
+ - {}
+ - adminToken: []
+ - adminTokenCookie: []
parameters:
- in: query
name: format
@@ -2161,27 +2458,39 @@ paths:
Curated subset suitable for sharing. Notes flagged `private` are excluded
by SQL filter at build time. See [Security & trust → Audit-bundle export
threat model](/security#audit-bundle-export-threat-model).
+
+ Gated: allowed when the `flag.settings.admin.export.audit_bundle` feature
+ flag resolves to `on`, or when `flag.focus.workflow` is `other_handoff`.
+ Otherwise 403. On a network-exposed instance the admin token is required
+ too — `/api/export` is a gated read prefix, and this is a mutation.
security:
- sameOrigin: []
requestBody:
- required: true
+ required: false
+ description: |
+ Optional. The bundle always covers every tracked app — there is no
+ per-app selection on this route.
content:
application/json:
schema:
type: object
properties:
- appIds:
- type: array
- items:
- type: integer
- format: int64
- description: Defaults to all tracked apps.
- includePrivacyProfile:
+ recommenderName:
+ type: string
+ nullable: true
+ description: |
+ Free-text name attached to your annotations on the
+ recipient's view. Falls back to "your friend".
+ includeRecommenderProfile:
+ type: boolean
+ default: true
+ description: Include the privacy profile you assessed against.
+ migrationFlow:
type: boolean
default: false
- attribution:
- type: string
- description: Free-text name attached to your annotations on the recipient's view.
+ description: |
+ Mark the bundle as a same-user migration, so the receiving
+ install skips the provenance banner.
responses:
"200":
description: Bundle ready for download.
@@ -2189,6 +2498,24 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AuditBundle"
+ "403":
+ description: |
+ Export not enabled for this focus — neither the feature flag nor an
+ `other_handoff` workflow allows it.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "429":
+ description: More than 5 exports in a minute.
+ headers:
+ Retry-After:
+ schema:
+ type: integer
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
/api/import/audit-bundle:
post:
@@ -2248,7 +2575,7 @@ paths:
summary: List server-local rolling snapshots
responses:
"200":
- description: Snapshot files in `data/snapshots/`.
+ description: Snapshot files in `data/backups/` under the data directory.
content:
application/json:
schema:
@@ -2320,6 +2647,13 @@ paths:
description: |
Self-host-friendly summary suitable for the Settings page or a support
bundle. Never includes annotations, AI keys, or app names.
+
+ On a network-exposed instance this read requires the admin token —
+ `/api/deployment/` is one of the gated read prefixes.
+ security:
+ - {}
+ - adminToken: []
+ - adminTokenCookie: []
responses:
"200":
description: Diagnostics payload.
@@ -2335,6 +2669,13 @@ paths:
description: |
Same shape as `/api/deployment/diagnostics` but framed as a single
copy/paste blob suitable for pasting into a GitHub issue.
+
+ On a network-exposed instance this read requires the admin token —
+ `/api/deployment/` is one of the gated read prefixes.
+ security:
+ - {}
+ - adminToken: []
+ - adminTokenCookie: []
responses:
"200":
description: Plain-text bundle.
diff --git a/backup-and-restore.mdx b/backup-and-restore.mdx
index 3e1b393..f0cf9f7 100644
--- a/backup-and-restore.mdx
+++ b/backup-and-restore.mdx
@@ -32,7 +32,7 @@ Restore is the inverse — replace the file with the app stopped, then start it
## Backup bundle (versioned, app-aware)
-`GET /api/backup/export` produces a single JSON file with every app, label, snapshot, annotation, notification, focus state, and feature-flag override. It's versioned, so a bundle exported from v1.1 restores cleanly into v1.2 and later.
+`GET /api/backup/export` produces a single JSON file with every app, label, snapshot, annotation, notification, focus state, and feature-flag override. The envelope carries an integer `version` field — currently `1` — which is the *bundle format* version, not the app version. A bundle restores into any release that understands that format or a later one; a bundle from a newer format is refused with a message telling you to upgrade, rather than being misparsed. Tables missing on an older schema are skipped, so a newer bundle still restores what an older install can hold.
From the UI: **Settings → Backup → Export bundle**.
@@ -50,7 +50,7 @@ The bundle is plain JSON — you can `gzip` it for storage and restore from the
### Restore with preview
-`POST /api/backup/restore` requires a typed-confirmation step (you type `RESTORE` to confirm) and surfaces a preview of what will be replaced before any writes happen.
+In the UI, restore is a two-step flow: `POST /api/backup/preview` shows what will be replaced, then you type `RESTORE` to confirm before anything is written.
From the UI: **Settings → Backup → Import bundle** → choose the file → confirm in the preview dialog.
@@ -64,31 +64,57 @@ curl -X POST http://localhost:3000/api/backup/preview \
--data-binary @backup-2026-05-09.json
# 2. Restore (after reviewing the preview)
-curl -X POST "http://localhost:3000/api/backup/restore?confirm=RESTORE" \
+curl -X POST http://localhost:3000/api/backup/restore \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:3000" \
--data-binary @backup-2026-05-09.json
```
-Restore wipes the existing database and reloads from the bundle. Take a file copy of `data/privacy.db` first as a safety net.
+The typed `RESTORE` string is a browser-side guardrail only — the route reads no `confirm` parameter, so a `curl` restores immediately. Take a file copy of `data/privacy.db` first as a safety net; restore wipes the existing database and reloads from the bundle.
+
+Restore is rate-limited to 3 attempts per 10 minutes and requires the admin token when one is configured.
+
+### Restoring a bundle from another install
+
+Every exported bundle carries an HMAC-SHA256 signature computed with a per-install key at `/backup-signing.key`. That key is generated on first use and never leaves the machine, so a bundle exported by a *different* install — a different container, a different Mac, a fresh data directory — cannot verify. Restoring one is rejected:
+
+```
+409 { "error": "...", "code": "untrusted_backup", "signaturePresent": true }
+```
+
+This is deliberate: it makes "reset, then restore my own backup" a trusted operation and forces cross-install restores to be a decision rather than an accident. To proceed, opt in explicitly:
+
+```bash
+curl -X POST "http://localhost:3000/api/backup/restore?allowUntrusted=1" \
+ -H "Content-Type: application/json" \
+ -H "Origin: http://localhost:3000" \
+ --data-binary @backup-from-old-machine.json
+```
+
+The header `x-allow-untrusted-backup: 1` does the same thing. Both accept `1` or `true`. Rows from an untrusted bundle are still sanitised on the way in, but the signature no longer tells you where the file came from — only restore a bundle you can vouch for yourself.
+
+
+ The desktop and web UIs do not send this flag today. A cross-install restore has to go through the API until they do.
+
## Server-local rolling snapshots
-privacytracker can keep its own rolling snapshots in the data directory under `data/snapshots/` — useful if you don't want to wire up an external backup tool but still want a few historical copies on hand.
+privacytracker can keep its own rolling snapshots in the data directory under `data/backups/` — useful if you don't want to wire up an external backup tool but still want a few historical copies on hand.
-Configure in **Settings → Backup → Server snapshots**:
+They are **off by default**. Turn them on and configure them in **Settings → Backup → Automatic local snapshots**:
| Setting | Default | Notes |
|---|---|---|
-| **Schedule** | weekly | `daily` / `weekly` / `monthly` / `off` |
-| **Retention** | 4 | how many recent snapshots to keep; older ones are auto-pruned |
-| **Take one now** | — | one-click snapshot for ad-hoc backups |
+| **Create snapshots automatically** | off | nothing is written until you enable this |
+| **Snapshot interval** | Daily (24h) | Every 6 hours / Every 12 hours / Daily / Weekly |
+| **Snapshots to keep** | 10 | how many recent snapshots to keep; older ones are auto-pruned. Clamped to 1–100 |
+| **Create snapshot now** | — | one-click snapshot for ad-hoc backups |
-Snapshots are stored as the same versioned JSON bundle format. List them via `GET /api/backup/snapshots`; download a specific one via `GET /api/backup/snapshots/`.
+The server checks on startup and on each 30-minute tick, writing a snapshot when the interval is due. Snapshots use the same versioned JSON bundle format. List them via `GET /api/backup/snapshots`; download a specific one via `GET /api/backup/snapshots/`.
## Migrating between install paths
-The same versioned bundle works as the migration vehicle.
+The same versioned bundle works as the migration vehicle, with one caveat: the destination is a different install, so the bundle's signature won't verify there. Every migration below needs the `allowUntrusted` opt-in described in [Restoring a bundle from another install](#restoring-a-bundle-from-another-install), which today means finishing the restore through the API rather than the UI.
### Docker → desktop app
@@ -107,13 +133,22 @@ The same versioned bundle works as the migration vehicle.
Download the latest `.dmg` from [Releases](https://github.com/privacykey/privacytracker/releases/latest), drag to `/Applications`, launch.
- Open the desktop app → **Settings → Backup → Import bundle** → choose `privacytracker-export.json` → type `RESTORE` to confirm.
+ Launch the desktop app once so it creates its data directory, then restore through the API with the untrusted opt-in — the bundle was signed by the Docker install, so the UI path rejects it:
+
+ ```bash
+ curl -X POST "http://localhost:3000/api/backup/restore?allowUntrusted=1" \
+ -H "Content-Type: application/json" \
+ -H "Origin: http://localhost:3000" \
+ --data-binary @privacytracker-export.json
+ ```
+
+ The desktop sidecar serves on `127.0.0.1`; check **Settings → Admin → Deployment Diagnostics** for the port if 3000 is taken.
### Desktop app → Docker
-Same flow in reverse. Export from the desktop app's **Settings → Backup**, drop the JSON next to `docker-compose.yml`, start the stack, import via the UI.
+Same flow in reverse. Export from the desktop app's **Settings → Backup**, drop the JSON next to `docker-compose.yml`, start the stack, then restore with `?allowUntrusted=1` against `http://localhost:3000`.
### Source checkout → Docker (or vice versa)
@@ -129,6 +164,7 @@ A bundle restore replaces app data and settings. It does **not** restore:
- **Auto-update binaries** — those are managed by Tauri's updater on the desktop app or `brew upgrade` on Homebrew.
- **Environment variables** — `AUDITOR_ADMIN_TOKEN`, etc., are external to the database. Re-set them in your runtime config.
- **iPhone import helper output** — those are stand-alone `.txt` / `.csv` files; back them up separately if you want to keep them.
+- **The backup signing key** — `backup-signing.key` sits next to `privacy.db` in the data directory, deliberately outside the database so a reset or restore can't invalidate your existing bundles. A plain file copy of the whole `data/` directory carries it along; a bundle export doesn't.
## Disaster recovery
@@ -161,14 +197,14 @@ If `data/privacy.db` is corrupted (rare; SQLite is durable, but not invincible t
## Verifying a backup
-A good habit is to periodically verify your backups by restoring one into a throwaway instance:
+A good habit is to periodically verify your backups by restoring one into a throwaway instance. The throwaway has a fresh data dir and therefore a fresh signing key, so the bundle reads as untrusted there — pass `allowUntrusted=1`:
```bash
# Spin up a temporary container pointed at a fresh data dir
docker run --rm -p 3001:3000 -v $(pwd)/test-data:/app/data privacytracker:latest &
# Import the bundle
-curl -X POST "http://localhost:3001/api/backup/restore?confirm=RESTORE" \
+curl -X POST "http://localhost:3001/api/backup/restore?allowUntrusted=1" \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:3001" \
--data-binary @backup-2026-05-09.json
@@ -180,3 +216,5 @@ rm -rf test-data
```
If the counts match what you expect from the source instance, your backup is good.
+
+To verify the signature as well as the contents, copy `backup-signing.key` from the source install's data directory into `test-data/` before starting the container. The restore then succeeds without the flag — which proves the bundle really was produced by that install and hasn't been altered since.
diff --git a/changelog.mdx b/changelog.mdx
index 82d4d0b..8411f37 100644
--- a/changelog.mdx
+++ b/changelog.mdx
@@ -9,52 +9,145 @@ This page mirrors [`CHANGELOG.md`](https://github.com/privacykey/privacytracker/
Looking for what's currently in flight (not yet released)? See the [privacytracker Plane board](https://sites.plane.so/issues/39b6604351894f09a5e903acce37d265).
-## v1.1.0 — Feature flags + focus model
-
-Added the two-axis focus system that drives every user-facing surface in the
-app: audience (`self` / `loved_one` / `guardian`) × goals (`understand`,
-`declutter`, `minimal`, plus an `accessibility` modifier). Defaults resolve
-through a sparse rule engine in `lib/feature-flag-rules.ts`; any user can
-override individual flags via Settings → Developer Options → Feature flags.
-
-Highlights:
-
-- **New onboarding flow** — `/welcome` is now an audience picker, followed
- by `/onboard/goals` for goals + accessibility. Skipping any step falls
- back to sensible defaults; "Try with sample data" loads 10 demo apps in
- sessionStorage so a brand-new install has something to explore.
-- **Migration** — existing users have their `user_intent` row mapped to
- the new `flag.focus.audience` + `flag.focus.goal.*` keys eagerly on
- startup. Failure surfaces a recoverable error UI; up to 3 retries before
- a "Reset DB" escape hatch.
-- **Annotations** — per-app freeform notes with markdown, soft-delete +
- 30-second undo, tags (`concern` / `positive` / `follow_up` / `other`),
- per-note visibility (`export` / `private`), debounced auto-save, and
- full activity-log integration.
-- **Audit-bundle export** — `audience.loved_one` users can export apps +
- labels + AI summaries + annotations as a versioned JSON file. Private
- notes are unconditionally excluded by SQL filter. The recipient imports
- via the new `/onboard` "Import audit bundle" method.
-- **Coachmark tour** — react-joyride-driven, goal-aware step inclusion.
- Resumes mid-session via sessionStorage; replayable from `/help/focus`.
-- **Settings — Your focus card** at the top of Settings, with chips
- showing current audience + goals and an Adjust button that opens the
- pre-populated picker.
-- **Kill-switch** — `flag.devopts.feature_flag_system.enabled` reverts
- every flag to its hard default if anything goes sideways post-release.
-- **Quiet hours** — per-audience defaults (`guardian` 22:00–07:00,
- `loved_one` 21:00–08:00) deferring notifications via the new
- `notifications.not_before` column.
-- **i18n infrastructure** — `next-intl` registered with English-only
- locale bundle. The route tree is flat (no per-locale URL prefix); the
- active locale is selected at render time from app settings.
-- **Bug-report template unification** — `privacy-policy.yml` folded into
- `bug_report.yml` with a `report-type` dropdown + `current-flag-state`
- field for Dev-Options-driven reports.
-
-Schema changes: new `feature_flag_overrides` and `annotations` tables;
-`notifications.not_before` column. All migrations run idempotently on boot
-via `instrumentation.ts`.
-
-Documentation: see [`.github/wiki/Feature-Flag-Inventory.md`](/develop/feature-flags)
-for the full design, rule tables, and PR breakdown.
+## [Unreleased]
+
+### Added
+
+- Canned sample data now populates every app-detail surface: each demo app
+ gets its hand-written AI policy summary stored as a real, ready analysis
+ (lens grid, highlights, and source preview render without any AI provider),
+ declared accessibility features on the Accessibility tab, and — for
+ Instagram — a policy-change history that lights up the recent-change banner
+ and the rating-shift strip.
+- The app-detail axe gate now also scans the Accessibility, AI Policy, and
+ Change History tabs (activated and populated), and the app-detail E2E spec
+ covers the change-review panel, the privacy-label accordion toggle, and all
+ three tabs.
+- Blocking accessibility gate in CI: axe-core scans of the welcome screen,
+ onboarding import flow, dashboard, app detail, and mobile navigation, plus
+ keyboard-only coverage of the onboarding path.
+- Community health documentation — contributing guide, code of conduct,
+ support guide, pull-request template, and code owners.
+- `pnpm screenshots` — captures a consistent set of UI screenshots from
+ the built-in demo fixture, for docs and release notes.
+- A `justfile` collecting the common workflows — `just --list` shows the
+ set, covering the dev loop, the desktop (Tauri) build, Docker, and the
+ verification suites.
+
+### Changed
+
+- **Settings is now four pages instead of one.** Your preferences, sync,
+ policies and admin each get their own address
+ (`/dashboard/settings/you`, `/sync`, `/policies`, `/admin`), so a page
+ loads only what it needs and you can link someone straight to the part
+ you mean. Existing links and bookmarks — including the ones in
+ notifications — still land in the right place.
+
+- **First-run experience.** Per-feature toggles moved behind an "Advanced"
+ disclosure, illustrated goal cards shrunk on phones, and the primary action
+ pinned to a sticky footer so it stays reachable. AI summaries now default to
+ **Disabled** instead of preselecting a provider, and a stored "disabled"
+ choice is honoured on reload. "Save & generate" stays disabled until the
+ provider's fields validate. New users now get exactly one post-onboarding
+ guide — the task checklist — instead of a checklist plus a coachmark tour
+ pointing at it.
+- Import candidate selection is now a native radio group: keyboard-operable
+ with arrow keys, and announced correctly by screen readers.
+
+### Fixed
+
+- Light-theme colour contrast on the app-detail page now meets WCAG AA:
+ not-declared accessibility rows no longer dim their text below the
+ threshold, the "Declared by developer" tag and the preference-key legend
+ use theme-aware colours, the AI-policy note boxes no longer render dark
+ navy in light mode, and the change-history chart's +N/−N counters use the
+ theme palette instead of fixed chart-band colours.
+- **Failed update checks now back off** instead of retrying forever. An
+ installation with no internet access used to attempt a connection to
+ GitHub — and wait out its timeout — every time anything asked whether an
+ update was available. Consecutive failures now widen the gap between
+ attempts (15 minutes, doubling, up to a day). Checking manually still
+ makes a real attempt straight away.
+- **The SQLite database is now private by default** — `0700` on the data
+ directory, `0600` on the database and its write-ahead-log files. Existing
+ installations are tightened automatically on their next start. The file
+ holds your full app inventory, your notes, and (for now) any configured AI
+ provider key.
+- Accessible names restored for the icon-only home and "Add Apps" links in the
+ compact navigation bar.
+- Expandable section headers no longer nest their info-tooltip button inside
+ the toggle, and the collapsed notes sidebar no longer keeps invisible
+ controls in the tab order.
+- The app-name entry field has a real label rather than only a placeholder.
+- Colour contrast now meets WCAG AA across the interface: link and secondary
+ text colours, the accent blue in light mode, and the navigation drawer were
+ all below the 4.5:1 threshold in places.
+- Nested panels — activity-log rows, the developer tools cards, and
+ import-history banners — now have visible backgrounds. They were styled
+ against `--surface-1/2/3` and `--border-1/2` design tokens that were never
+ actually defined, so they rendered transparent. Defining those tokens for
+ light, dark, high-contrast and reduce-transparency modes also clears the
+ last dark-only boxes on the app-detail policy blocks (the scrollable source
+ and trace wells) and in the Live Text illustration, which drew a dark phone
+ frame in the light theme.
+
+### Security
+
+- Documented in the README that a configured AI provider key is stored in
+ plaintext in the local database. Moving desktop keys into the OS keychain is
+ planned.
+
+## [0.1.2] — 2026-06-12
+
+### Added
+
+- Animated onboarding purpose cards and dashboard vignettes.
+- Periodic health check with non-destructive self-heal for long-running
+ instances.
+- Read-only deployment mode for shared or kiosk installs.
+
+### Changed
+
+- Privacy-label icons and ordering aligned with Apple's own presentation.
+- Full internationalisation sweep — the interface is translator-ready and
+ round-trips through Crowdin.
+- Onboarding hardening across the four import paths.
+
+## [0.1.1] — 2026-05-20
+
+### Fixed
+
+- **Launch-time freeze affecting every copy of v0.1.0.** The bundled Node
+ helper exited immediately with `MODULE_NOT_FOUND` for `@swc/helpers`,
+ leaving an unresponsive window. The packaging step had dereferenced pnpm's
+ symlinked `node_modules` layout, moving `@swc/helpers` out of Node's
+ resolution path; it now preserves those relative symlinks verbatim through
+ both staging and the release tarball.
+
+ The auto-updater runs *after* the Node helper boots, so it never fired on
+ v0.1.0 — anyone on that version had to install v0.1.1 manually. Every
+ install from v0.1.1 onward self-updates normally.
+
+## [0.1.0] — 2026-05-18
+
+Initial beta release, available as a macOS app, a Docker image, or a plain
+Next.js app.
+
+### Added
+
+- App Store privacy-label tracking with change detection over time.
+- Historical back-fill to Q1 2021 via the Wayback Machine.
+- Focus-tailored dashboard adapting to who the device belongs to (yourself, a
+ loved one, or someone you support) and what you want from it.
+- Four onboarding import paths: typed names, CSV/TXT upload, Apple
+ Configurator on desktop, and screenshot OCR.
+- Changelog timelines, privacy heatmap, per-app severity strips, an editable
+ home-card layout, and exportable audit bundles.
+- AI-generated privacy-policy summaries with a bring-your-own provider model.
+- Background sync with a notifications bell, and crash-safe resume across the
+ live, Wayback, and privacy-policy jobs.
+
+[Unreleased]: https://github.com/privacykey/privacytracker/compare/v0.1.2...HEAD
+[0.1.2]: https://github.com/privacykey/privacytracker/compare/v0.1.1...v0.1.2
+[0.1.1]: https://github.com/privacykey/privacytracker/compare/v0.1.0...v0.1.1
+[0.1.0]: https://github.com/privacykey/privacytracker/releases/tag/v0.1.0
diff --git a/configuration.mdx b/configuration.mdx
index 5e582c8..ea5a9b7 100644
--- a/configuration.mdx
+++ b/configuration.mdx
@@ -24,7 +24,8 @@ browser cannot load the app at all.
| `PRIVACYTRACKER_ALLOWED_HOSTS` | (unset) | Comma-separated `Host` values to accept, **appended** to the always-allowed loopback set (`localhost`, `127.x`, `::1`). Supports `*.suffix` wildcards. Listing a non-loopback entry also flips the instance to network-exposed, which makes `AUDITOR_ADMIN_TOKEN` mandatory. |
| `PRIVACYTRACKER_NETWORK_EXPOSED` | (unset) | Boolean (`1`/`true`/`yes`/`on`). Forces the network-exposed posture without naming a host — for a reverse proxy that rewrites `Host`. |
| `PRIVACYTRACKER_TRUST_PROXY` | (unset) | Boolean. Honour `X-Forwarded-Host` / `X-Forwarded-For` for the host allowlist, rate-limit keys, and audit IPs. This is an operator assertion that a trusted proxy sits in front — leave it unset for a direct bind, where those headers are attacker-controlled. |
-| `PRIVACYTRACKER_BIND_HOST` | (unset) | Explicit bind interface. A specific non-loopback IP implies network-exposed. `HOSTNAME` is deliberately *not* trusted as a bind signal, because Docker sets it to the container ID. |
+| `PRIVACYTRACKER_BIND_HOST` | (unset) | Explicit bind interface. A specific non-loopback IP implies network-exposed. |
+| `HOSTNAME` | (set by Docker) | Fallback bind signal, honoured **only** when its value parses as an IP literal or a loopback token — never as a hostname, because Docker sets it to the container ID. When it does parse, it feeds the same classification as `PRIVACYTRACKER_BIND_HOST` and can therefore flip the network-exposed posture that makes `AUDITOR_ADMIN_TOKEN` mandatory. The Tauri launcher relies on this, passing `HOSTNAME=127.0.0.1`. `PRIVACYTRACKER_BIND_HOST` takes precedence when both are set. |
Trust is derived from deployment config, never from request headers — a
@@ -38,9 +39,12 @@ browser cannot load the app at all.
| Variable | Default | Purpose |
|---|---|---|
| `PRIVACYTRACKER_RUNTIME` | (unset) | Set to `desktop` by the Tauri sidecar launcher. Gates desktop-only surfaces and feature-flag resolution. |
-| `WORKER_DISABLED` | (unset) | Suppresses the background scheduler tick — used by tests and by processes that should not run sync. |
+| `DEPLOYMENT` | (auto-detected) | Override the deployment label: `docker`, `tauri`, `homebrew`, or `node`. Auto-detection probes `/.dockerenv`, then the cgroup, then `HOMEBREW_PREFIX`, and falls back to `node`. The label's only effect is which upgrade command the update banner suggests — set it when auto-detection guesses wrong for your packaging. |
+| `WORKER_DISABLED` | (unset) | Set to `1` to force bulk SQLite writes to run inline on the main thread instead of on the `worker_threads` DB writer. Used by tests, and implied during production builds. Only the literal `1` is honoured. It does **not** disable background sync. |
| `BUILD_STANDALONE` | (unset) | Build-time flag for `pnpm build:standalone` (the Tauri sidecar bundle). |
+Nothing suppresses the background scheduler tick from the environment — the 30-minute ticker in `instrumentation.ts` reads no environment variable. To stop scheduled sync, set the sync schedule to `manual` (see [Background sync](#background-sync)).
+
Everything else (AI provider, sync schedule, Wayback toggles, notification prefs, focus state, feature-flag overrides) lives in `app_settings` and is changed through the UI.
## AI providers
@@ -78,7 +82,7 @@ ollama serve
# Model: llama3.2
```
-The summariser scores each policy against the lenses defined in `POLICY_TOPIC_GUIDES` (`collection_scope`, `ads_marketing`, `third_party_sharing`, `retention`, `user_rights`, etc.). Summaries only regenerate when the document hash changes — cosmetic edits don't burn AI calls.
+The summariser scores each policy against eight fixed lenses — `collection_scope`, `product_use`, `ads_marketing`, `third_party_sharing`, `tracking_analytics`, `user_controls`, `data_retention`, `children_minors` — defined as `POLICY_LENSES` in `lib/policy-summary-meta.ts`. Stored summaries are rebuilt against that list and unrecognised keys are dropped, so those eight are the only ones you'll see in `summary_json` or in an API response. Summaries only regenerate when the document hash changes — cosmetic edits don't burn AI calls.
## Background sync
@@ -86,11 +90,38 @@ Two knobs:
| Setting | Where | Notes |
|---|---|---|
-| `sync_schedule` | Settings → Sync | `daily` or `weekly` cadence. The 30-minute ticker in `instrumentation.ts` checks `getSchedulerStatus().isDue` and calls `runScheduledSync()`. |
+| `sync_schedule` | Settings → Sync | `manual`, `daily`, or `weekly`. **Defaults to `manual`** — no background sync runs until you pick a cadence. The 30-minute ticker in `instrumentation.ts` checks `getSchedulerStatus().isDue` and calls `runScheduledSync()`; on `manual` it is never due. |
| `sync_running` | Internal | Cross-request mutex stored in `app_settings`. Respect it if you add another entry point that could trigger a sync. |
Apple's 429 handling is built in: the runner bails out of the loop on the first 429, records a `partial` activity row with `rateLimited` totals, and clears state + mutex cleanly so the next 30-minute tick can retry fresh. That's deliberately different from a process kill — 429 is a recoverable condition.
+## Notification webhooks
+
+The bell is always on. On top of it, privacytracker can POST notifications to a chat webhook — Slack, Discord, Teams, or any endpoint that accepts JSON. Off by default.
+
+On the desktop app, the *Keep privacytracker running in the background* wizard sets this up alongside sync cadence and quiet hours. On any build, four `app_settings` keys drive it:
+
+| Setting | Values | Notes |
+|---|---|---|
+| `notification_webhook_url` | a URL, or `''` | Empty disables delivery. Validated with the same SSRF-defended checker as the AI base URL, so private-network destinations are rejected. |
+| `notification_webhook_format` | `slack` / `discord` / `teams` / `generic` | `slack` and `discord` post rendered text; `teams` posts a MessageCard; `generic` posts `{ title, text, notifications[] }`. |
+| `notification_webhook_frequency` | `immediate` / `daily_summary` / `weekly_summary` / `off` | `immediate` fires as each notification lands. The summaries batch unread notifications and are posted from the 30-minute tick once a day or week has elapsed. |
+| `notification_webhook_last_sent` | epoch ms | Written by the summary tick. Don't set it by hand. |
+
+Test a URL before committing to it — this fires a sample payload and writes nothing:
+
+```bash
+curl -X POST http://localhost:3000/api/notifications/webhook-test \
+ -H "Content-Type: application/json" \
+ -H "Origin: http://localhost:3000" \
+ --data '{"url":"https://hooks.slack.com/services/...","format":"slack"}'
+# {"ok":true,"status":200}
+```
+
+Delivery failures are logged and swallowed — a dead webhook never blocks the in-app notification write. `GET /api/settings` returns the URL masked; posting the masked value back leaves the stored URL untouched.
+
+Payloads carry app names and change summaries. That is a real egress path — see [Security → What data leaves your device](/security#what-data-leaves-your-device).
+
## Wayback import
Back-fills label history from archive.org. The importer picks one target per calendar quarter starting from **Q1 2021 (1 February 2021)** — the earliest era when Apple's HTML carries privacy nutrition labels — and walks forward to the current quarter. The floor is exposed in code as `APP_STORE_HISTORICAL_FLOOR` (with `APP_STORE_WEB_LAUNCH` kept as an alias for back-compat).
diff --git a/cookbook.mdx b/cookbook.mdx
index 32e9bfc..45b6c32 100644
--- a/cookbook.mdx
+++ b/cookbook.mdx
@@ -27,13 +27,13 @@ If your situation matches one of these, follow the recipe. If it almost-but-not-
## Track every app on a kid's iPad
-**Audience:** `guardian`. **Goal:** `understand`. **Install path:** desktop app or Docker — whichever you'll keep running long-term.
+**Audience:** `guardian`. **Goal:** `monitor`. **Install path:** desktop app or Docker — whichever you'll keep running long-term.
The job: get a clean view of everything installed on a child's device, surface what's high-severity, and notify you only when something actually changes — not every time Apple's HTML reflows.
- Open privacytracker, pick **Guardian** when the audience picker appears, and choose **Understand** as the primary goal. The defaults that flow from this combination raise severity-tier visibility on the dashboard, default the bell to *only changed apps*, and apply 22:00–07:00 quiet hours. You can override any of this later in **Settings → Developer Options → Feature flags**, but the defaults are tuned for exactly this scenario.
+ Open privacytracker, pick **Guardian** when the audience picker appears, and choose **Monitor my apps for changes** as the primary goal. The defaults that flow from this combination raise severity-tier visibility on the dashboard, default the bell to *only changed apps*, and apply 22:00–07:00 quiet hours. You can override any of this later in **Settings → Developer Options → Feature flags**, but the defaults are tuned for exactly this scenario.
Plug the iPad into your Mac, run a Finder backup (no encryption needed), then from your privacytracker source checkout (or any clone of the main repo):
@@ -55,7 +55,7 @@ The job: get a clean view of everything installed on a child's device, surface w
For the bell itself, leave the default **Only when categories change** filter on. Re-scrapes that produce no diff don't add to the unread badge.
- If you want plain-English summaries of the policies for apps that collect *Precise Location*, *Sensitive Contacts*, or *Financial Info*, configure an AI provider under **Settings → AI**. The summariser scores each policy against the lenses in `POLICY_TOPIC_GUIDES` (`collection_scope`, `ads_marketing`, `third_party_sharing`, `retention`, etc.) — exactly the questions a guardian wants answered. Hosted providers run a fraction of a cent per app; a local Ollama setup is free at runtime.
+ If you want plain-English summaries of the policies for apps that collect *Precise Location*, *Sensitive Contacts*, or *Financial Info*, configure an AI provider under **Settings → AI**. The summariser scores each policy against eight fixed lenses (`collection_scope`, `product_use`, `ads_marketing`, `third_party_sharing`, `tracking_analytics`, `user_controls`, `data_retention`, `children_minors`) — exactly the questions a guardian wants answered. Hosted providers run a fraction of a cent per app; a local Ollama setup is free at runtime.
See [Configuration → AI providers](/configuration#ai-providers) for the full setup. To keep it scoped, summarise apps one at a time with the **Regenerate** button on each app's detail page — cover the high-severity apps and skip the kid's wallpaper app.
@@ -84,7 +84,7 @@ The job: you're picking between two apps that do similar things — say, two jou
The Compare view shows privacy labels in a side-by-side severity-coded grid. *Data Used to Track You* lines up against *Data Used to Track You*, *Data Linked to You* against the same — so you can see which app has Location for tracking and which one only collects it for app functionality.
- The Compare view's bottom panel pulls each app's AI-summarised privacy policy and renders them in two columns, lens-by-lens — *collection scope*, *ads & marketing*, *third-party sharing*, *retention*, *user rights*, *children*, *security*, *disclosure*. Differences in language at the *retention* lens, especially, are usually where the real distinction between two superficially-similar apps shows up.
+ The Compare view's bottom panel pulls each app's AI-summarised privacy policy and renders them in two columns, lens-by-lens — *collection scope*, *product use*, *ads & marketing*, *third-party sharing*, *tracking & analytics*, *user controls*, *data retention*, *children & minors*. Differences in language at the *data retention* lens, especially, are usually where the real distinction between two superficially-similar apps shows up.
No AI configured? You can still click through to each app's policy URL on its detail page and read the source.
@@ -138,13 +138,13 @@ The job: run privacytracker on always-on hardware (Synology, Unraid, a Raspberry
}
```
- Caddy gets you automatic Let's Encrypt TLS if `privacytracker.lan` resolves on the public internet, or self-signed certs for a LAN-only deploy. The `Host` header forwarding is non-negotiable — privacytracker enforces a same-origin CSRF check on every mutating verb, so a proxy that strips the Host header will cause every action to fail with `origin_mismatch`. See [Troubleshooting → Reverse-proxy CSRF rejection](/troubleshooting#reverse-proxy-csrf-rejection) if you hit that.
+ Caddy gets you automatic Let's Encrypt TLS if `privacytracker.lan` resolves on the public internet, or self-signed certs for a LAN-only deploy. The `Host` header forwarding is non-negotiable — privacytracker enforces a same-origin CSRF check on every mutating verb, so a proxy that strips the Host header will cause every action to fail with 403 `{"error":"Cross-origin mutation rejected"}`. See [Troubleshooting → Reverse-proxy CSRF rejection](/troubleshooting#reverse-proxy-csrf-rejection) if you hit that.
Confirm the admin token from step 2 is taking effect:
```bash
- # Should fail with 403 (no token):
+ # Should fail with 401 {"error":"Admin token required"}:
curl -X POST https://privacytracker.lan/api/reset \
-H "Origin: https://privacytracker.lan"
@@ -154,7 +154,7 @@ The job: run privacytracker on always-on hardware (Synology, Unraid, a Raspberry
-H "X-Auditor-Admin-Token: $AUDITOR_ADMIN_TOKEN"
```
- The token is verified with `crypto.timingSafeEqual`. Failed attempts append to `audit_log` with the requester IP — review periodically via **Settings → Diagnostics → Audit log**.
+ The token is verified with `crypto.timingSafeEqual`. Failed attempts append to `audit_log` as `admin_token.login.invalid`. There's no UI for that table — review it with `sqlite3 data/privacy.db "SELECT datetime(created_at/1000,'unixepoch'), action, actor_ip FROM audit_log ORDER BY created_at DESC LIMIT 50;"`.
For full hardening, see [Security & trust](/security#authentication-and-access-control).
@@ -165,7 +165,7 @@ The job: run privacytracker on always-on hardware (Synology, Unraid, a Raspberry
0 3 * * * rsync -a /volume1/docker/privacytracker/data/ user@backup-host:/backups/privacytracker-$(date +\%F)/
```
- Or use the in-app **Settings → Backup → Server snapshots** to keep rolling JSON bundles, then sync those out. See [Backup & restore](/backup-and-restore) for the full options.
+ Or turn on **Settings → Backup → Automatic local snapshots** to keep rolling JSON bundles under `data/backups/`, then sync those out. They're off by default. See [Backup & restore](/backup-and-restore) for the full options.
Anyone in the house can browse to `https://privacytracker.lan`, the 30-minute background sync runs unattended, the bell shows newly-changed apps without anyone needing to think about it, and `data/privacy.db` is replicated nightly to a different machine. The admin token gates `POST /api/reset` so a curious guest on the LAN can't wipe the database.
@@ -214,10 +214,12 @@ The job: an organisation needs evidence of an app's privacy disclosures going ba
curl -X POST http://localhost:3000/api/export/audit-bundle \
-H "Origin: http://localhost:3000" \
-H "Content-Type: application/json" \
- --data '{"appIds": [324684580, 333903271]}' \
+ --data '{"recommenderName": "Compliance Team"}' \
-o privacy-audit-$(date +%F).json
```
+ The bundle covers **every** tracked app — there's no per-app selection on this route. The body takes three optional fields: `recommenderName` (the attribution shown next to your annotations, defaulting to *your friend*), `includeRecommenderProfile` (defaults to true), and `migrationFlow`. Rate-limited to 5 exports per minute.
+
The bundle is a versioned JSON file containing the apps, every snapshot (live + Wayback), AI summaries if you have them, and any annotations marked `visibility = 'export'`. Private notes are excluded by SQL filter at build time — there is no force-include.
For long-term archive, store the bundle alongside the original Wayback capture URLs (they're embedded in the bundle). The capture URLs use the `id_` suffix (`/web/id_/`) so Apple's HTML comes through clean of archive.org's toolbar injector — meaning the capture is forensically usable too, not just human-readable.
@@ -229,13 +231,15 @@ The job: an organisation needs evidence of an app's privacy disclosures going ba
## Run a privacy review with a partner or family member
-**Audience:** `loved_one`. **Goal:** `understand`. **Install path:** any.
+**Audience:** `loved_one`. **Goal:** `monitor`. **Install path:** any.
The job: you and a partner (or another household member, or a friend you're helping) want to look at your respective app collections together — comparing notes on what each of you has installed and how concerned you each are about specific apps. You don't want to install another tool on their machine; you want to share a curated bundle they can read.
On first run pick **Loved one**, or change it later in **Settings → Your focus → Adjust**. This unlocks the audit-bundle export workflow and adjusts the dashboard's emphasis from severity-grids toward annotation visibility.
+
+ The unlocking is literal, not a UI hint: the `loved_one` audience rule resolves `flag.settings.admin.export.audit_bundle` to `on`, which is one of the two things `POST /api/export/audit-bundle` accepts — so the export is callable straight away, with `flag.focus.workflow` still at `custom`. The other route in is the focus wizard's "I'm preparing a bundle to hand to them" answer, which sets the workflow to `other_handoff`; that's what a `self` or `guardian` user needs, since for them the flag defaults to `off` and the route returns 403 until they answer it or switch the flag on under **Settings → Developer Options → Feature flags**.
Walk through your tracked apps and write annotations on the ones that warrant a note. Each annotation has:
diff --git a/develop/architecture.mdx b/develop/architecture.mdx
index ffb780b..d33beb0 100644
--- a/develop/architecture.mdx
+++ b/develop/architecture.mdx
@@ -36,13 +36,19 @@ privacytracker/
│ ├── apps/[id]/ app detail + change history
│ └── globals.css design tokens, severity colours, .legal-layout primitives
├── lib/ server-side logic — most of the product lives here
-├── tools/ out-of-band scripts (icon generation, ios-app-import companion)
-├── homebrew-tap/ working copy of the cask formula
+├── src-tauri/ Rust desktop shell + sidecar lifecycle
+├── scripts/ out-of-band scripts (ios-app-import companion, screenshot capture, standalone staging)
+├── deploy/ reference reverse-proxy stacks (caddy/, traefik/)
+├── tests/ node:test suites + Playwright specs
+├── locales/ en.json / zh.json translation bundles
+├── public/ static assets served by Next
+├── docs/ in-repo notes (the published site lives in privacytracker-docs)
├── data/privacy.db SQLite database (gitignored; bind-mounted in Docker)
-├── .github/wiki/ canonical long-form documentation
└── AGENTS.md / CLAUDE.md coding-agent instructions
```
+The cask formula is **not** in this repo — it lives in [privacykey/homebrew-tap](https://github.com/privacykey/homebrew-tap) at `Casks/privacytracker.rb`, regenerated by the release workflow on every tag.
+
## Core data flow
One scrape, one pass through the seven-step loop:
@@ -163,7 +169,7 @@ Apple's 429 handling is deliberately different from a process kill. The runner b
## Database
-`lib/db.ts` exports a **singleton** better-sqlite3 instance. Pragmas set on open: `journal_mode = WAL`, `busy_timeout = 5000`, `foreign_keys = ON`. Path is always `/data/privacy.db`, created on demand.
+`lib/db.ts` exports a **singleton** better-sqlite3 instance. Pragmas set on open: `journal_mode = WAL`, `busy_timeout = 5000`, `foreign_keys = ON`. Path defaults to `/data/privacy.db` and is overridden by `PRIVACYTRACKER_DATA_DIR`, which the Tauri shell injects to point at the OS app-data directory — see [Configuration](/configuration#environment-variables). Created on demand, data dir at mode `0700` and the DB itself at `0600`. During the Next.js build phase the path is `:memory:` instead.
Schema is owned by two things in `lib/db.ts`:
diff --git a/develop/build-from-source.mdx b/develop/build-from-source.mdx
index 62d571b..e64a08d 100644
--- a/develop/build-from-source.mdx
+++ b/develop/build-from-source.mdx
@@ -11,7 +11,7 @@ If you just want to use privacytracker, the [self-host quickstart](/quickstart)
- privacytracker pins to Node 24 in `engines.node` (`>=24.0.0 <26.0.0`). Earlier versions will fail `npm install` on the better-sqlite3 native build.
+ privacytracker pins to Node 24 in `engines.node` (`>=24.0.0 <27.0.0`), and `.nvmrc` is `24`. Earlier versions will fail `npm install` on the better-sqlite3 native build.
```bash
# macOS, with Homebrew
@@ -60,7 +60,7 @@ This produces the same artefact that ships in the desktop app and Docker image.
```bash lint
-npm run lint # ESLint flat config for Next 16
+npm run lint # Ultracite (Biome) — lint + format check
npm run typecheck # tsc --noEmit
```
diff --git a/develop/contributing.mdx b/develop/contributing.mdx
index 9117c30..4e99f13 100644
--- a/develop/contributing.mdx
+++ b/develop/contributing.mdx
@@ -65,7 +65,7 @@ The contribution areas with the highest leverage and the lowest barrier:
The full pre-flight is fast — run all four:
```bash
-npm run lint # ESLint flat config for Next 16
+npm run lint # Ultracite (Biome) — lint + format check
npm run typecheck # tsc --noEmit
npm test # focused node:test suite
npm run lint:i18n # locales/*.json key parity against en.json
@@ -113,7 +113,7 @@ Most parser breakage takes one of two forms:
| Symptom | Fix |
|---|---|
-| **App's labels stop appearing entirely** | Apple changed the shelf shape. Walk the three-layer fallback chain in `saveToDb`; the breaking change is usually a renamed key (`shelfMapping` → `something_else`) or a new wrapper object. |
+| **App's labels stop appearing entirely** | Apple changed the shelf shape. Walk the four-layer fallback chain in `prepareScrapeWritePlan` (`lib/scraper.ts`, called from `fetchAndParseApp`); the breaking change is usually a renamed key (`shelfMapping` → `something_else`) or a new wrapper object. |
| **Privacy-policy link not detected** | Apple's "Developer's Privacy Policy" aria-label sometimes uses a curly `'` instead of straight `'`, or localised aria-labels diverge. Widen the regex in `lib/scraper.ts → extractPrivacyPolicyUrl`. |
Always include the failing App Store URL in the PR description so reviewers can verify the fix without a separate hunt.
@@ -165,7 +165,7 @@ A few conventions to know before you open a PR:
Bug reports are valuable on their own — you don't have to fix what you found.
-- **Bugs and feature requests:** [github.com/privacykey/privacytracker/issues](https://github.com/privacykey/privacytracker/issues), `bug_report.yml` template. Paste your support bundle (**Settings → Diagnostics → Copy support bundle**) — it has system info, app version, sync state, and the most recent activity log without any of your tracked apps' data.
+- **Bugs and feature requests:** [github.com/privacykey/privacytracker/issues](https://github.com/privacykey/privacytracker/issues), `bug_report.yml` template. Paste your support bundle (**Settings → Admin → Deployment Diagnostics → Copy support bundle**) — it has system info, app version, sync state, and the most recent activity log without any of your tracked apps' data.
- **Security findings:** [GitHub Private Vulnerability Reporting](https://github.com/privacykey/privacytracker/security/advisories/new). Don't open a public issue for security.
## Code of conduct
diff --git a/develop/feature-flags.mdx b/develop/feature-flags.mdx
index e172dd3..6a27418 100644
--- a/develop/feature-flags.mdx
+++ b/develop/feature-flags.mdx
@@ -61,16 +61,22 @@ Two pieces of state.
### Active focus
-Four rows in `app_settings`, representing a 2-axis model (audience × goals):
+Seven rows in `app_settings` — audience, four goal booleans, a workflow, and a timestamp:
```
flag.focus.audience = 'self' | 'loved_one' | 'guardian'
-flag.focus.goal.understand = 'true' | 'false'
-flag.focus.goal.declutter = 'true' | 'false'
-flag.focus.goal.minimal = 'true' | 'false' (mutually exclusive with understand/declutter)
+flag.focus.goal.monitor = 'true' | 'false'
+flag.focus.goal.cleanup = 'true' | 'false'
+flag.focus.goal.minimal = 'true' | 'false' (mutually exclusive with monitor/cleanup)
flag.focus.goal.accessibility = 'true' | 'false' (modifier — combines with any primary goal)
+flag.focus.workflow = 'self_monitor' | 'self_cleanup' | 'other_handoff' | 'other_monitor' | 'custom'
+flag.focus.updated_at = epoch milliseconds of the last write
```
+`setActiveFocus()` in `lib/feature-flag-storage.ts` writes all seven in one transaction. The primary goals were re-keyed: `understand` became `monitor` and `declutter` became `cleanup`. The old names survive only as migration inputs (step 6 below) — nothing reads or writes them at runtime.
+
+`flag.focus.workflow` is inferred from audience + goals when the caller doesn't pass one, and collapses to `custom` whenever the answer is ambiguous — which is every `loved_one` and `guardian` flow, since those need a handoff-vs-monitor answer the goal tiles don't ask for. The workflow gates audit-bundle export: `POST /api/export/audit-bundle` returns 403 unless `flag.settings.admin.export.audit_bundle` resolves to `on` **or** `workflowAllowsAuditBundle()` (`lib/focus-workflow.ts`) sees `other_handoff`.
+
### Per-flag override
```sql
@@ -84,7 +90,7 @@ CREATE TABLE IF NOT EXISTS feature_flag_overrides (
);
```
-Overrides are **explicit** — an absent row means "inherit the computed default from audience + goals". This is what lets dev options show derivation (*"on · because `goal.understand`"*) and spot overrides (*"off (custom) · would be on because `goal.understand`"*) without mutating any focus config.
+Overrides are **explicit** — an absent row means "inherit the computed default from audience + goals". This is what lets dev options show derivation (*"on · because `goal.monitor`"*) and spot overrides (*"off (custom) · would be on because `goal.monitor`"*) without mutating any focus config.
`'collapsed'` is a third legal value for flags that model "visible but not expanded by default" — accordions, privacy-type cards, the a11y panel, the risk-tier legend. Roughly 8–12 flags use it; the rest stay boolean.
@@ -122,15 +128,16 @@ Three edits, in this order:
-## Migration (v1.1)
+## Migration
-Migration runs eagerly in `instrumentation.ts` in 5 ordered steps:
+`lib/migrations/v1_feature_flags.ts` (`MIGRATION_VERSION = 2`) runs eagerly in `instrumentation.ts`, in 6 ordered steps:
1. **Schema check** — `CREATE TABLE IF NOT EXISTS feature_flag_overrides` plus any new `app_settings` indices.
2. **`user_intent` → audience+goals** — read the legacy key, write `flag.focus.audience` + `flag.focus.goal.*`, drop `user_intent`.
3. **`notification_prefs` absorb** — read the JSON blob, write each notification type as `flag.notifications.types.*` rows, drop the blob.
4. **Callout rename** — drop override rows for old keys without carrying their values across.
5. **Quarantine check** — scan `feature_flag_overrides` for rows whose `flag_key` isn't in the registry's `FlagKey` union, mark them `quarantined = 1`. Conversely, rehabilitate previously-quarantined rows whose keys are now known.
+6. **Focus goal rename** — move `flag.focus.goal.understand` onto `flag.focus.goal.monitor` and `flag.focus.goal.declutter` onto `flag.focus.goal.cleanup`, then delete the old rows. An install from before the re-key keeps its focus; a new key that already holds a value wins.
Each step is idempotent. Failures abort the migration and surface an error UI with the failing step name. Up to 3 retries; after that, the error screen offers a "Reset DB" escape hatch.
diff --git a/develop/overview.mdx b/develop/overview.mdx
index 1d8a204..6b056da 100644
--- a/develop/overview.mdx
+++ b/develop/overview.mdx
@@ -48,7 +48,7 @@ Day-to-day commands you'll actually use:
```bash
npm install # requires Node 24 LTS
npm run dev # http://localhost:3000
-npm run lint # ESLint flat config for Next 16
+npm run lint # Ultracite (Biome) — lint + format check
npm run typecheck # TypeScript without emitting files
npm test # focused node:test suite
npm run lint:i18n # check locales/*.json key parity against en.json
@@ -61,15 +61,17 @@ npm run build # production build
privacytracker/
├── app/ Next.js App Router pages + API routes
├── lib/ server-side logic — most of the product lives here
-├── tools/ out-of-band scripts (icon generation, ios-app-import companion)
+├── src-tauri/ Rust desktop shell + sidecar lifecycle
+├── scripts/ out-of-band scripts (ios-app-import companion, screenshots, standalone staging)
+├── deploy/ reference reverse-proxy stacks (caddy/, traefik/)
+├── tests/ node:test suites + Playwright specs
├── data/privacy.db SQLite database (gitignored)
├── locales/ i18n bundles (en.json is the source of truth)
-├── .github/wiki/ canonical long-form documentation (kept for parity with this site)
├── AGENTS.md coding-agent instructions for Codex
└── CLAUDE.md coding-agent instructions for Claude Code
```
-Anything not here — release pipeline, code signing, the GitHub Actions that publish notarized builds — lives in the wiki and is intentionally out of scope for this docs site. The point of the public docs is to help users self-host and contributors get productive; release engineering is internal.
+Anything not here — release pipeline, code signing, the GitHub Actions that publish notarized builds — is intentionally out of scope for this docs site. The point of the public docs is to help users self-host and contributors get productive; release engineering is internal.
## Reporting issues
diff --git a/develop/tauri.mdx b/develop/tauri.mdx
index 31ad0fd..e0449da 100644
--- a/develop/tauri.mdx
+++ b/develop/tauri.mdx
@@ -42,14 +42,18 @@ When `privacytracker.app` launches:
The bundled Node binary (`src-tauri/binaries/node-`) runs the standalone Next.js bundle (`server.js`) with `PORT=` and `HOSTNAME=127.0.0.1`. The bundle binds to localhost only — no external network exposure.
-
- The Rust shell polls `http://127.0.0.1:/api/ready` with a 30-second timeout. Once it returns `{ "ok": true }`, the WebView loads `http://127.0.0.1:/` and the user sees the dashboard.
+
+ `sidecar::wait_until_ready` polls `http://127.0.0.1:/api/apps` every 250 ms with a 2-second per-request timeout, and treats any response with status < 500 as ready — it never parses the body. The overall deadline is `READY_TIMEOUT`, **60 seconds** (the in-source comment records the bump from 30s: standalone-tree extraction takes 5–10s on a slow disk, and Next.js compiles the route on first hit). Once a probe succeeds, the WebView loads `http://127.0.0.1:/` and the user sees the dashboard.
+
+ `/api/ready` exists, but nothing in the Tauri shell uses it — it's the Docker/compose healthcheck and the smoke-test probe. It answers `{ status: "ready" | "not_ready", checks }`, not `{ ok: true }`.
-
- `tauri-plugin-process` watches the sidecar PID. If it dies (panic, OOM, SIGTERM from `kill -9`), Tauri restarts it. The user sees a brief reload; the SQLite WAL recovery handles any in-flight writes.
+
+ Nothing restarts the sidecar. It's spawned once in `main.rs`, and `SidecarHandle` only ever terminates it — if the Node process dies mid-session, the WebView is left pointing at a dead port and the user has to relaunch.
+
+ The liveness relationship runs in the opposite direction. `sidecar.rs` passes `PRIVACYTRACKER_PARENT_PID` (Tauri's own PID) into the child, and `lib/parent-watchdog.ts` — installed from `instrumentation.ts` — probes that PID with signal 0 every 3 seconds after a 5-second initial delay, calling `process.exit(0)` when it disappears. That's what stops a Force Quit, `kill -9`, or Rust panic from leaving an orphaned Node process holding the SQLite WAL and the listening port. The clean-quit path is handled by SIGTERM from the Rust side; the watchdog covers the unclean ones.
- `Cmd-Q` triggers a `SIGTERM` to the sidecar followed by a 5-second drain window. The Node process flushes any in-flight DB writes (better-sqlite3 is synchronous, so this is usually instant) and exits.
+ `Cmd-Q` — and tray Quit, menu-bar Quit, and updater restart, all of which route through `RunEvent::ExitRequested` — sends `SIGTERM` to the sidecar's whole process group, then polls `try_wait()` every 50 ms for up to **3 seconds**. Still alive at the deadline, it escalates to `SIGKILL`; SQLite WAL crash recovery makes that safe either way. On Windows there's no SIGTERM step — the code falls straight through to `TerminateProcess`.
@@ -133,25 +137,43 @@ The updater has no opt-out switch — the plugin is registered unconditionally i
├── privacy.db SQLite DB (the same shape Docker sees)
├── privacy.db-wal WAL journal while running
├── privacy.db-shm shared-memory file
-├── snapshots/ server-local rolling backups (Settings → Backup)
-└── logs/
- ├── app.log Rust shell log (window events, IPC)
- └── sidecar.log Node sidecar log (HTTP, DB, AI calls)
+├── backup-signing.key per-install HMAC key for backup bundles (0600)
+├── backups/ server-local rolling snapshots (Settings → Backup)
+├── standalone/ Next.js standalone tree, extracted on first run
+└── .standalone-extracted-from-size-mtime marker: which tarball the tree came from
+```
+
+There is no `logs/` directory here. `tauri-plugin-log` writes to Tauri's `LogDir` — `~/Library/Logs/org.privacykey.privacytracker/` on macOS — as a single rolling file holding the Rust shell's log. **Settings → Show log folder** opens it (the `open_log_dir` command).
+
+There is no `sidecar.log` at all. The Node sidecar's stdout and stderr are `Stdio::inherit()`, so its output goes to the parent's stdout — visible only when the app is launched from a terminal:
+
+```bash
+/Applications/privacytracker.app/Contents/MacOS/privacytracker
```
-The `data/` directory you'd see in a Docker install corresponds to this entire folder on the desktop. A backup bundle from one is restorable into the other — see [Backup & restore → Migrating between install paths](/backup-and-restore#migrating-between-install-paths).
+That's the diagnostic mode to reach for when boot fails.
+
+The `data/` directory you'd see in a Docker install corresponds to this entire folder on the desktop. A backup bundle from one is restorable into the other, with the untrusted opt-in — see [Backup & restore → Migrating between install paths](/backup-and-restore#migrating-between-install-paths).
## Touch ID
-`src-tauri/src/touch_id.rs` exposes a Tauri command that prompts the user via macOS LocalAuthentication for biometric confirmation. We use it for one thing today: confirming `POST /api/reset` from the desktop UI without requiring the user to type the admin token. The command returns a boolean to the WebView, which then includes the admin token (read from process env, never the WebView) in the Reset request.
+`src-tauri/src/touch_id.rs` wraps macOS LocalAuthentication (`LAPolicy::DeviceOwnerAuthentication` — Touch ID with login-password fallback) and exposes it as the `authenticate_touch_id` command, with a 60-second timeout. On non-macOS hosts it always returns `true`.
+
+Three call sites today:
+
+1. **cfgutil app uninstall.** A native prompt runs before the destructive `cfgutil` subprocess. The wizard's "type DELETE" step is webview-side, so a compromised webview could invoke the command directly — the native modal is what it can't fake. Documented at [Devices → Device actions](/devices#device-actions).
+2. **Enabling "Require unlock"** in Desktop settings — confirms the user before persisting `require_unlock`. Note the gate on window reveal isn't implemented yet: `reveal_main_window` always reveals immediately and defers the lock overlay to the webview, which doesn't draw one.
+3. **The "Test Touch ID" button** in Desktop settings.
+
+`POST /api/reset` involves neither. `SettingsView.resetAllData()` is a bare `fetch("/api/reset", { method: "POST" })` with no headers — the route is protected by the same-origin proxy check, a 30-per-10-minutes rate limit, and the admin token when one is required, returning 401 otherwise. The admin token reaches the server as the `pt_admin_token` cookie the user establishes through `/api/auth/admin-token/login`; the Rust side never reads it from the environment and never injects it.
-This is a desktop-only convenience. The web/Docker surfaces still require manual token entry.
+Touch ID is also deliberately *not* used to derive backup-encryption keys — bundles have to stay portable to the web build and to other machines.
## Things that commonly trip people up
- **Sidecar fails to start with `NODE_MODULE_VERSION X does not match Y`.** better-sqlite3's native module was built against a different Node ABI. Either rebuild it (`npm rebuild better-sqlite3`) against the bundled Node version, or update the bundled Node to match. The [Tauri Bundled Node guide on the Plane board](https://sites.plane.so/issues/39b6604351894f09a5e903acce37d265) has the recipe.
-- **App opens but the WebView is blank.** Sidecar didn't reach `/api/ready` within the 30-second timeout. Tail `~/Library/Application Support/privacytracker/logs/sidecar.log` — usually it's a database lock from a previous bad shutdown (delete `privacy.db-wal` and `privacy.db-shm` while the app is closed) or a port conflict (rare on macOS, more common on Linux).
-- **Auto-update gets stuck.** Tauri caches the *checked* state for 24h regardless of outcome. To force a re-check: quit the app, delete `~/Library/Caches/com.privacykey.privacytracker/`, relaunch.
+- **App opens but the WebView is blank.** The sidecar didn't answer `/api/apps` within the 60-second readiness deadline. Relaunch from Terminal (`/Applications/privacytracker.app/Contents/MacOS/privacytracker`) to see the sidecar's stdout, and check `~/Library/Logs/org.privacykey.privacytracker/` for the Rust shell's log. Usually it's a database lock from a previous bad shutdown (delete `privacy.db-wal` and `privacy.db-shm` while the app is closed) or a port conflict (rare on macOS, more common on Linux).
+- **Auto-update gets stuck.** Tauri caches the *checked* state for 24h regardless of outcome. To force a re-check: quit the app, delete `~/Library/Caches/org.privacykey.privacytracker/`, relaunch.
- **`spctl --assess` fails with `code object is not signed at all`.** The bundle was tampered with after signing, or the download was truncated. Re-download from GitHub releases.
## Where the code lives
@@ -163,6 +185,6 @@ This is a desktop-only convenience. The web/Docker surfaces still require manual
| Touch ID command | `src-tauri/src/touch_id.rs` |
| Standalone build pipeline | `scripts/stage-standalone.mjs`, `next.config.js` (look for `BUILD_STANDALONE`) |
| macOS release workflow | `.github/workflows/macos-release.yml` |
-| Cask formula (Homebrew tap) | `homebrew-tap/Casks/privacytracker.rb` |
+| Cask formula (Homebrew tap) | `Casks/privacytracker.rb` in the separate [privacykey/homebrew-tap](https://github.com/privacykey/homebrew-tap) repo — regenerated on every release by `macos-release.yml`, so manual edits are overwritten |
-The release pipeline itself (signing, notarization, the GitHub Actions specifically) is intentionally not documented in the public docs — that's release-engineering material kept in the wiki and the private signing-setup repo.
+The release pipeline itself (signing, notarization, the GitHub Actions specifically) is intentionally not documented in the public docs — that's release-engineering material kept in the private signing-setup repo.
diff --git a/develop/versioning.mdx b/develop/versioning.mdx
index c83869f..4d19df7 100644
--- a/develop/versioning.mdx
+++ b/develop/versioning.mdx
@@ -3,7 +3,9 @@ title: Versioned docs
description: "How privacytracker versions its docs site, when to cut a new version, and the steps to add one in Mintlify."
---
-privacytracker follows semver: the `..` of the *app* matches the version this docs site documents. The docs site is currently single-version (always reflects `main`), but the framework is in place to add per-version sub-sites when v1.2 ships with breaking config changes.
+privacytracker follows semver: the `..` of the *app* matches the version this docs site documents. The shipped version today is **0.1.2**, and the docs site is single-version — it always reflects `main`. Nothing has been archived yet.
+
+This page is the recipe for the first time that changes. The `v1.1` / `v1.2` version numbers in the examples below are illustrative placeholders for *previous minor* and *new minor*; substitute the real pair when you cut one.
This page is for the docs maintainer cutting a new version, not the casual contributor. Day-to-day editing flows through [`CONTRIBUTING.md`](https://github.com/privacykey/privacytracker-docs/blob/main/CONTRIBUTING.md).
diff --git a/faq.mdx b/faq.mdx
index 2358be5..809898a 100644
--- a/faq.mdx
+++ b/faq.mdx
@@ -17,13 +17,14 @@ description: "Common questions about what privacytracker is, what it isn't, and
- Three things, all opt-in or transparent:
+ Four things, all opt-in or transparent:
1. **App Store HTML** — fetched from `apps.apple.com` to read public privacy labels. Same network call your browser makes when you visit an app's page.
2. **Privacy-policy text** — fetched from the developer's own domain when you've enabled AI summaries. The fetcher follows redirects and respects standard `Cache-Control` headers.
3. **AI provider calls** — only if you've configured one. The policy text (chunked if necessary) is sent to OpenAI, Anthropic, or your local OpenAI-compatible endpoint. Nothing else — no app names, no usage, no telemetry.
+ 4. **Notification webhooks** — only if you've pasted a webhook URL. App names and change summaries are POSTed to whatever chat service you pointed it at. Off unless you set it up; see [Configuration → Notification webhooks](/configuration#notification-webhooks).
- privacytracker has no analytics or telemetry of its own. The SQLite database, all settings, all your annotations — everything stays on your machine.
+ Beyond those, privacytracker has no analytics or telemetry of its own. The SQLite database, all settings, all your annotations stay on your machine.
@@ -32,19 +33,19 @@ description: "Common questions about what privacytracker is, what it isn't, and
- **OpenAI / Anthropic (hosted)** — typically a fraction of a cent per summary. A privacy policy of ~20 KB summarised through `gpt-4o-mini` or `claude-haiku-*` runs around USD $0.001-0.005 per app. Re-summarisation only happens when the policy text actually changes (we hash and skip otherwise), so the long-tail cost stays low.
- **Local model (Ollama, LM Studio, llama.cpp)** — free at runtime; you pay in disk space and a one-time model download. Quality varies; Llama 3.1 8B and Qwen 2.5 7B both produce usable summaries on the lens prompts.
- Set a hard cap in **Settings → AI → Daily token budget** if you want belt-and-braces cost protection.
+ There's no in-app spend cap — privacytracker never sees your provider's billing. For belt-and-braces protection, set a spending limit on the API key itself at OpenAI or Anthropic and use that key here. The hash-skip behaviour above is the other half of it: a policy that hasn't changed is never re-summarised.
Yes — that's the `audience: guardian` mode. One Docker container or one self-hosted instance can track apps across multiple people's devices; the import flow accepts batched `.txt` / `.csv` files from the [iPhone import helper](/installation#iphone-import-helper) so you can ingest a family member's app list in one pass.
- privacytracker has no built-in user accounts or per-user partitioning — it's a single-user app at the data layer. If you need separate views for different family members, run separate instances (different ports, different `data/` directories) or rely on the `loved_one` audience export-bundle workflow to share a curated subset.
+ privacytracker has no built-in user accounts or per-user partitioning — it's a single-user app at the data layer. If you need separate views for different family members, run separate instances (different ports, different `data/` directories) or use the audit-bundle handoff workflow to share a curated subset.
- The parser has a three-layer fallback chain (`shelfMapping.privacyTypes.items` → `privacyHeader.seeAllAction.pageData.shelves` → generic `pageData.shelves`) so most shape changes absorb without a release. When Apple ships a fully breaking change — twice in the project's history so far — the fix is usually 5-10 lines in `lib/scraper.ts` once we have a known-broken example.
+ The parser has a four-layer fallback chain (`shelfMapping.privacyTypes.items` → `privacyHeader.seeAllAction.pageData.shelves` → generic `pageData.shelves` → `extractFromShoebox` for the historical Ember/FastBoot shape, which is what lets the Wayback importer reach back to Q1 2021) so most shape changes absorb without a release. When Apple ships a fully breaking change — twice in the project's history so far — the fix is usually 5-10 lines in `lib/scraper.ts` once we have a known-broken example.
- If you hit a parser failure, the support bundle in **Settings → Diagnostics** captures everything we'd need to fix it; paste it into a GitHub issue.
+ If you hit a parser failure, the support bundle under **Settings → Admin → Deployment Diagnostics** captures everything we'd need to fix it; paste it into a GitHub issue.
@@ -56,9 +57,9 @@ description: "Common questions about what privacytracker is, what it isn't, and
Yes:
- - **Backup bundle** — `GET /api/backup/export` produces a versioned JSON file with every app, label, snapshot, annotation, and notification. Restorable via `POST /api/backup/restore` with a typed-confirmation preview. See [Backup & restore](/backup-and-restore).
+ - **Backup bundle** — `GET /api/backup/export` produces a versioned JSON file with every app, label, snapshot, annotation, and notification. Restore it via `POST /api/backup/restore`; the UI adds a preview and a typed confirmation, and a bundle exported by a *different* install needs an explicit untrusted opt-in. See [Backup & restore](/backup-and-restore).
- **CSV / JSON dump** — `GET /api/export?format=csv|json` for a flat data dump suitable for piping into a spreadsheet or another tool.
- - **Audit bundle** — for the `audience: loved_one` workflow, a curated subset (apps, labels, AI summaries, exportable annotations) suitable for sharing with another household member or a regulator.
+ - **Audit bundle** — a curated subset (apps, labels, AI summaries, exportable annotations) suitable for sharing with another household member or a regulator. Available when your focus workflow is `other_handoff`, or when the audit-bundle export flag is switched on.
Private annotations are unconditionally excluded from audit-bundle exports at the SQL level — there is no force-include path.
@@ -82,7 +83,7 @@ description: "Common questions about what privacytracker is, what it isn't, and
- English today. The interface is built on `next-intl` with the localisation framework already wired up, but only the English bundle ships right now; the active language is selected at render time from your app settings, so other locales light up as they're translated. Adding a language is a contributor task — see [Translations](/develop/translations).
+ English (`en`) and Simplified Chinese (`zh`), both at full key parity. Switch between them under **Settings → Language**. The interface is built on `next-intl`; the active language is resolved on every server-rendered request from the `NEXT_LOCALE` cookie, falling back to English when it's absent or holds an unsupported value. Adding a language is a contributor task — see [Translations](/develop/translations).
diff --git a/glossary.mdx b/glossary.mdx
index 2c64c4f..00cef11 100644
--- a/glossary.mdx
+++ b/glossary.mdx
@@ -43,19 +43,21 @@ A short reference for terms that show up across the app, the API, and the codeba
**Policy version.** A captured version of a developer's privacy policy (URL, fetched text, SHA-256 hash, AI summary if generated). Stored in `policy_versions`. Re-summarisation only runs when the hash changes.
-**Lens.** One topic area an AI summary covers — `collection_scope`, `ads_marketing`, `third_party_sharing`, `retention`, `user_rights`, `children`, `security`, `disclosure`. Defined in `POLICY_TOPIC_GUIDES` in `lib/privacy-policy.ts`.
+**Lens.** One topic area an AI summary covers. Eight of them, in this order: `collection_scope`, `product_use`, `ads_marketing`, `third_party_sharing`, `tracking_analytics`, `user_controls`, `data_retention`, `children_minors`. Defined in `POLICY_LENSES` in `lib/policy-summary-meta.ts`; the per-lens prompt guidance lives in `POLICY_TOPIC_GUIDES` in `lib/privacy-policy.ts`. Summaries are rebuilt against that list, and keys outside it are discarded — so `summary_json` and the API only ever carry these eight.
**Chunking.** Splitting a long policy into ~12 KB pieces for small/local models. Triggered by the `providerLikelyNeedsChunking` flag in `lib/ai-config.ts`. Per-lens prompts are generated against each chunk and merged.
-**AI debug log.** Append-only diagnostic table showing every AI call's provider, model, prompt size, response status, and timing. Inspectable from **Settings → AI → Debug log** or `GET /api/ai/debug-log`.
+**AI debug log.** Append-only diagnostic table showing every AI call's provider, model, prompt size, response status, and timing. Off unless *Record AI prompts and responses* is enabled. Inspectable from **Settings → Admin → Developer Options** or `GET /api/ai/debug-log`.
## Focus and feature flags
-**Focus.** The user's combined choice of *audience* and *goals*. Drives every default in the feature-flag resolver. Stored as four `app_settings` rows: `flag.focus.audience` plus three `flag.focus.goal.*` booleans.
+**Focus.** The user's combined choice of *audience* and *goals*. Drives every default in the feature-flag resolver. Stored as seven `app_settings` rows: `flag.focus.audience`, four `flag.focus.goal.*` booleans, `flag.focus.workflow`, and `flag.focus.updated_at`.
**Audience.** One of `self`, `loved_one`, `guardian`. Determines moderate-weight defaults across the resolver.
-**Goal.** Mutually exclusive primary goal (`understand`, `declutter`, `minimal`) plus an optional `accessibility` modifier. The modifier combines with whichever primary goal is active.
+**Goal.** Mutually exclusive primary goal (`monitor`, `cleanup`, `minimal`) plus an optional `accessibility` modifier. The modifier combines with whichever primary goal is active. `monitor` and `cleanup` were formerly called `understand` and `declutter`; the boot migration moves the old `app_settings` rows onto the new keys.
+
+**Workflow.** The sixth of those rows, `flag.focus.workflow` — one of `self_monitor`, `self_cleanup`, `other_handoff`, `other_monitor`, `custom`. Inferred from audience + goals where that's unambiguous, `custom` otherwise. Defined in `lib/focus-workflow.ts`; its one behavioural effect today is that `other_handoff` opens audit-bundle export — one of the two ways that gate can be satisfied.
**Hard default.** The value a flag falls back to before any rules apply. Defined in `HARD_DEFAULTS` in `lib/feature-flag-rules.ts`. The kill-switch (`flag.devopts.feature_flag_system.enabled = off`) collapses every flag to its hard default.
@@ -79,9 +81,9 @@ A short reference for terms that show up across the app, the API, and the codeba
**Annotation.** A freeform per-app note. Supports markdown, soft-delete with 30-second undo, tags (`concern` / `positive` / `follow_up` / `other`), and per-note visibility (`export` / `private`). Stored in `annotations`.
-**Verdict.** A structured per-app judgement — `tracking_concern`, `acceptable`, `do_not_install`, plus an optional reason. Stored in `verdicts`. Distinct from annotations because verdicts are categorical and exportable in the audit bundle.
+**Verdict.** A structured per-app judgement — `safe`, `replace`, or `uninstall`, plus an optional `rationale`. Stored in `app_verdicts`, with a `CHECK` constraint on the three values. Distinct from annotations because verdicts are categorical and exportable in the audit bundle.
-**Audit bundle.** A versioned export of apps, labels, AI summaries, and exportable annotations + verdicts, suitable for sharing with a household member or a regulator. Available to the `audience: loved_one` workflow. Private notes (`visibility = 'private'`) are unconditionally excluded by SQL filter — there is no force-include path.
+**Audit bundle.** A versioned export of apps, labels, AI summaries, and exportable annotations + verdicts, suitable for sharing with a household member or a regulator. `POST /api/export/audit-bundle` allows it when the `flag.settings.admin.export.audit_bundle` flag resolves to `on`, or when `flag.focus.workflow` is `other_handoff`. The `loved_one` audience rule sets that flag to `on`, so picking that audience satisfies the first arm on its own; `self` and `guardian` need the `other_handoff` workflow or an explicit override. Private notes (`visibility = 'private'`) are unconditionally excluded by SQL filter — there is no force-include path.
**Shortlist.** A user-curated set of apps you want to keep an eye on without installing. Separate from tracked apps; you can shortlist an app you've never installed.
@@ -93,4 +95,4 @@ A short reference for terms that show up across the app, the API, and the codeba
**Standalone build.** The output of `npm run build:standalone` (with `BUILD_STANDALONE=1` set). Self-contained Next.js bundle suitable for the sidecar or any external runtime.
-**Admin token.** Optional shared secret (`AUDITOR_ADMIN_TOKEN`) that gates destructive routes when set. Verified with `crypto.timingSafeEqual`. Failed attempts log to `audit_log` with IP + user agent.
+**Admin token.** Optional shared secret (`AUDITOR_ADMIN_TOKEN`) that gates destructive routes when set, plus a list of sensitive read prefixes once the instance is network-exposed. Sent as the `X-Auditor-Admin-Token` header or the `pt_admin_token` cookie. Verified with `crypto.timingSafeEqual`. Failed attempts log to `audit_log` as `admin_token.login.invalid`.
diff --git a/hardening.mdx b/hardening.mdx
index b4c1d1f..9e656ea 100644
--- a/hardening.mdx
+++ b/hardening.mdx
@@ -27,13 +27,15 @@ Pick the most restrictive box you can live with — every step below is calibrat
**Hardening checklist:** TLS via reverse proxy, admin token set, dev endpoints blocked, off-host backups. The `cookbook` recipe at [Self-host on a home NAS for the household](/cookbook#self-host-on-a-home-nas-for-the-household) walks through this end-to-end.
- Reachable from anywhere. This is *not* a supported deployment model — privacytracker has no rate limiting, no user accounts, and no DDoS protection of its own. If you absolutely must, do all of Tier 2 plus: a rate-limiting reverse proxy (Cloudflare / a paid provider), IP allowlists, and an off-host TLS termination. Better: use Tailscale, WireGuard, or another mesh-VPN to keep the actual surface on a trusted network and only expose VPN authentication to the public internet.
+ Reachable from anywhere. This is *not* a supported deployment model — privacytracker has no user accounts and no DDoS protection, and its [internal rate limiter](/security#rate-limiting) is defence-in-depth rather than an edge control: without `PRIVACYTRACKER_TRUST_PROXY` every caller shares one bucket per route, so it can't tell a flood from a busy household. If you absolutely must, do all of Tier 2 plus: a rate-limiting reverse proxy (Cloudflare / a paid provider), IP allowlists, and an off-host TLS termination. Better: use Tailscale, WireGuard, or another mesh-VPN to keep the actual surface on a trusted network and only expose VPN authentication to the public internet.
## TLS via reverse proxy
-privacytracker ships with no built-in TLS — it expects a reverse proxy to terminate. The proxy is also the natural place for rate limiting, IP allowlists, and structured access logs.
+privacytracker ships with no built-in TLS — it expects a reverse proxy to terminate. The proxy is also the natural place for edge rate limiting, IP allowlists, and structured access logs.
+
+The main repo ships working Compose stacks for two of the three proxies below — `deploy/caddy/` (Caddyfile, `compose.yaml`, `.env.example`) and `deploy/traefik/` (`compose.yaml`, `.env.example`). If you cloned the repo, start from those rather than the snippets here; the snippets are the minimum that makes the same-origin check work, not a complete deployment.
@@ -48,7 +50,7 @@ privacytracker ships with no built-in TLS — it expects a reverse proxy to term
}
```
- Caddy auto-issues Let's Encrypt certificates if `privacytracker.lan` resolves on the public internet, or self-signed certs for a LAN-only deploy. The `Host` forwarding line is non-negotiable — privacytracker enforces a same-origin CSRF check, so a proxy that strips or rewrites the Host header will cause every mutation to fail with `origin_mismatch`.
+ Caddy auto-issues Let's Encrypt certificates if `privacytracker.lan` resolves on the public internet, or self-signed certs for a LAN-only deploy. The `Host` forwarding line is non-negotiable — privacytracker enforces a same-origin CSRF check, so a proxy that strips or rewrites the Host header will cause every mutation to fail with 403 `{"error":"Cross-origin mutation rejected"}`.
```yaml
@@ -121,7 +123,7 @@ docker compose up -d --force-recreate
For the desktop app, set it in the launchd plist or your shell profile so `privacytracker.app` inherits it. Confirm it's taking effect:
```bash
-# Should fail with 403 admin_token_required
+# Should fail with 401 {"error":"Admin token required"}
curl -X POST https://privacytracker.lan/api/reset \
-H "Origin: https://privacytracker.lan"
@@ -131,8 +133,14 @@ curl -X POST https://privacytracker.lan/api/reset \
-H "X-Auditor-Admin-Token: $AUDITOR_ADMIN_TOKEN"
```
+Error bodies here are human-readable prose, not stable machine codes — assert on the status, not the string.
+
The token is verified with `crypto.timingSafeEqual` so token comparison runs in constant time. Failed attempts append to `audit_log` with the requester IP — see [Review the audit log](#review-the-audit-log) below.
+Setting the token also turns on the browser-session path: the Settings panel exchanges it once via `POST /api/auth/admin-token/login` for an 8-hour HttpOnly `pt_admin_token` cookie, so household members don't paste a secret into every request and no script running in the page can read it. `POST /api/auth/admin-token/logout` clears the session. See [Security → Browser sessions use a cookie](/security#browser-sessions-use-a-cookie-not-the-header).
+
+Once the instance is network-exposed, the token also gates a handful of sensitive **reads** — `/api/backup/`, `/api/export`, `/api/deployment/`, `/api/diagnostics/`, `/api/ai/debug-log`, `/api/import/`, `/api/desktop/diagnostics`. Any integration you point at those needs the header or the cookie too.
+
Rotating the token requires a process restart. The new value is read once on boot. Don't share the token in chat / Slack / email — treat it as a secret on the level of a database password.
@@ -144,10 +152,13 @@ privacytracker ships with `/api/dev/*` routes that are only safe in development:
| Route | What it does |
|---|---|
| `POST /api/dev/reset-changelog` | Truncates the changelog |
+| `POST /api/dev/seed-notification` | Inserts a notification row |
| `POST /api/dev/seed-sample-data` | Inserts 10 demo apps |
| `POST /api/dev/sync-stop` | Force-clears sync mutex |
| `POST /api/dev/wipe-apps` | Wipes every app and snapshot |
+All five ship in production builds — nothing in the source gates them to development.
+
These are gated by the same CSRF and admin-token checks, but for a Tier 2/3 deploy you should also block them at the proxy so they aren't reachable at all. Caddy:
```caddyfile
@@ -179,25 +190,27 @@ The `audit_log` table records destructive-route attempts and authentication fail
| 2 (trusted LAN) | Monthly skim. |
| 3 (public internet) | Weekly minimum, or pipe to a log aggregator. |
-From the UI: **Settings → Diagnostics → Audit log**. Direct SQL:
+There is no UI for this table — nothing in the app reads it. SQL is the only way in:
```bash
sqlite3 data/privacy.db "
- SELECT datetime(at/1000, 'unixepoch') AS ts, event, ip, user_agent
+ SELECT datetime(created_at/1000, 'unixepoch') AS ts, action, actor_ip, user_agent
FROM audit_log
- WHERE at > strftime('%s', 'now', '-30 days') * 1000
- ORDER BY at DESC
+ WHERE created_at > strftime('%s', 'now', '-30 days') * 1000
+ ORDER BY created_at DESC
LIMIT 50;
"
```
Things to react to:
-- **Repeated `admin_token_failed` from the same IP** — someone's brute-forcing. Rotate the token, then ban the IP at the proxy.
+- **Repeated `admin_token.login.invalid` from the same IP** — someone's brute-forcing. Rotate the token, then ban the IP at the proxy. `admin_token.login.rate_limited` and `admin_token.login.global_throttled` mean the built-in limiter already pushed back.
- **Unfamiliar IPs hitting destructive routes** — even if the token check passed (i.e., they have the token), an unfamiliar source means the token leaked. Rotate.
-- **`reset_invoked` you didn't trigger** — your data was wiped. Restore from backup; investigate how the actor got the token.
+- **`reset.success` you didn't trigger** — your data was wiped. Restore from backup; investigate how the actor got the token.
-The table is append-only by convention — no API route deletes from it. If it grows large, drop and recreate manually with the app stopped.
+Set `PRIVACYTRACKER_TRUST_PROXY` if you want real client IPs in `actor_ip`. Without it, `X-Forwarded-For` is attacker-controlled and deliberately ignored, so every row records the literal `local` — which makes "same IP" un-observable and the first bullet unusable.
+
+Don't treat the table as tamper-proof. Individual rows are only ever appended, never deleted or rewritten, but two routes wipe the whole table: `POST /api/admin/start-over` truncates it (`audit_log` is in `START_OVER_TABLES_TO_TRUNCATE`, `lib/reset-tables.ts`), and `POST /api/backup/restore` clears it and re-inserts whatever the bundle carried. Both are admin-token routes, so the actor you'd be investigating here — someone who has the token — can erase the trail behind them. `POST /api/reset` preserves it on purpose. If you need the trail to survive that, replicate it off-host on a schedule alongside your backups. If it grows large, drop and recreate manually with the app stopped.
## Off-host backups
@@ -208,9 +221,9 @@ A NAS's own RAID is not a backup. For Tier 2/3, schedule a daily replica of the
0 3 * * * privacytracker rsync -a /opt/privacytracker/data/ backup@nas-2:/backups/privacytracker-$(date +\%F)/
```
-Or use the in-app **Settings → Backup → Server snapshots** to keep rolling JSON bundles, then sync those off-host. The bundle format is forward-compatible — a v1.0 bundle restores cleanly into v1.1+. See [Backup & restore](/backup-and-restore) for the full options.
+Or use the in-app **Settings → Backup → Automatic local snapshots** to keep rolling JSON bundles under `data/backups/`, then sync those off-host. They're off by default — enable the toggle and pick an interval. The bundle carries an integer format version, and a bundle from an older format restores cleanly into a newer release. See [Backup & restore](/backup-and-restore) for the full options.
-Periodically verify a backup by restoring it into a throwaway instance — a backup you've never tested isn't a backup, it's a wish.
+Periodically verify a backup by restoring it into a throwaway instance — a backup you've never tested isn't a backup, it's a wish. Note the throwaway will reject the bundle as untrusted (different install, different signing key) unless you pass `allowUntrusted=1`; see [Restoring a bundle from another install](/backup-and-restore#restoring-a-bundle-from-another-install).
## Disable inbound auto-update on Tier 3
@@ -240,7 +253,16 @@ If your host firewall supports it, restrict outbound network from the privacytra
| `itunes.apple.com:443` | iTunes Search API (resolving app names) |
| `archive.org:443` + `web.archive.org:443` | Wayback imports |
| Your AI provider's host:port | Only if AI is enabled |
-| `api.github.com:443` | Tauri updater (desktop only) |
+| Your notification webhook's host:port | Only if you configured one |
+| `github.com:443` | Tauri updater on the desktop app — fetches `releases/latest/download/latest.json` |
+| `api.github.com:443` | Server-side update check, on **every** deployment |
+
+The last two are different things and people get them the wrong way round. `github.com` is the desktop updater's signed-patch endpoint. `api.github.com` is `lib/update-check.ts`, which runs on Docker, Node, Homebrew, and desktop alike: enabled by default, first probe 25 seconds after boot, then a 6-hour ticker that reaches the network at most once a day thanks to the cache. Block it believing it's desktop-only and you silently disable the update banner on a server install. To switch the check off properly rather than blackholing the host, set its `app_settings` flag — there's no UI toggle for it today:
+
+```bash
+sqlite3 data/privacy.db \
+ "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('update_check_enabled', 'false');"
+```
Anything else is unnecessary and a yellow flag if it appears in your egress logs. A simple iptables / nftables rule scoped to the privacytracker container's UID is enough; for serious lockdown, run the container in its own network namespace.
@@ -253,10 +275,10 @@ Before exposing your install to anyone other than yourself:
Verified by browser padlock + `curl -v` showing the cert.
- `curl -X POST .../api/sync/trigger -H "Origin: https://your-host"` returns 200 (or 409 *already_running*, never 403).
+ `curl -X POST .../api/sync/trigger -H "Origin: https://your-host"` returns 200, never 403. A sync already in flight is still a 200 — the body just carries `"skipped": true`.
- `curl -X POST .../api/reset -H "Origin: ..."` (no token) returns 403 *admin_token_required*.
+ `curl -X POST .../api/reset -H "Origin: ..."` (no token) returns 401 `{"error":"Admin token required"}`.
`curl -X POST .../api/dev/seed-sample-data -H "Origin: ..."` returns 404.
@@ -265,7 +287,7 @@ Before exposing your install to anyone other than yourself:
`ls /backups/privacytracker-*/privacy.db` on the backup host shows a recent file. Restored it into a throwaway instance and counts match.
- `SELECT * FROM audit_log ORDER BY at DESC LIMIT 20` shows nothing surprising.
+ `sqlite3 data/privacy.db "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 20;"` shows nothing surprising.
@@ -276,8 +298,8 @@ If any step fails, fix it before you tell anyone the URL.
privacytracker is a self-hosted single-user app. There are guardrails it doesn't have, by design:
- **No user accounts.** Anyone with browser access has full access. Treat the URL itself as a credential.
-- **No rate limiting on its own routes.** A bad actor inside the trust boundary can hit the API as fast as the host can answer. Rate-limit at the proxy if Tier 3.
+- **No per-caller rate limiting without a trusted proxy.** privacytracker does limit its own routes ([Rate limiting](/security#rate-limiting)) — 30/min on `/api/scrape`, 3 per 10 min on `/api/backup/restore`, 5/min on token login, and so on. But unless `PRIVACYTRACKER_TRUST_PROXY` is set, `X-Forwarded-For` is untrusted and every caller shares one bucket per route, so a bad actor inside the trust boundary consumes the same budget as the household. Rate-limit at the proxy if Tier 3.
- **No anti-CSRF beyond same-origin.** No token-cookie pairs, no SameSite=strict-only flows. The same-origin check + the admin token are the entire CSRF surface.
-- **No append-only enforcement on `audit_log` at the SQL level.** It's append-only by convention — `app/api/*/route.ts` doesn't expose a delete — but a process with shell access to `data/privacy.db` can rewrite it. The mitigation is: don't give untrusted actors shell access.
+- **No append-only enforcement on `audit_log` at the SQL level.** Individual rows are never rewritten — the only write is an insert — but two admin-token routes clear the whole table (`POST /api/admin/start-over` truncates it, `POST /api/backup/restore` replaces it), and a process with shell access to `data/privacy.db` can rewrite it directly. So the actor you are investigating after a token leak can erase the record of their own access. The mitigation is to replicate the log off-host, and not to give untrusted actors shell access.
These are conscious tradeoffs for the local-first, single-user model. If you need the missing pieces, you probably want a different tool (or a privacytracker instance per user).
diff --git a/installation.mdx b/installation.mdx
index 6b86e7f..997cdca 100644
--- a/installation.mdx
+++ b/installation.mdx
@@ -88,16 +88,32 @@ You can run the helper from the source repo even when the rest of privacytracker
## Behind a reverse proxy
-For trusted-LAN deployments, the project ships sample Caddy and Traefik configs. The headline rule is simple: privacytracker enforces a same-origin CSRF check on every destructive route, so the proxy must forward the original `Host` header. See the [Reverse Proxy guide on the Plane board](https://sites.plane.so/issues/39b6604351894f09a5e903acce37d265) for working samples.
+For trusted-LAN deployments the project ships working Compose stacks in the repo checkout — you don't need to write a proxy config from scratch:
+
+```
+deploy/caddy/Caddyfile reverse-proxy config, TLS + optional basic auth
+deploy/caddy/compose.yaml privacytracker + Caddy, ready to `docker compose up`
+deploy/caddy/.env.example image tag, hostname, admin token
+deploy/traefik/compose.yaml the Traefik equivalent
+deploy/traefik/.env.example
+```
+
+Copy the `.env.example` next to the compose file, fill it in, and bring the stack up from that directory.
+
+Three rules apply here, and the samples only cover the first. privacytracker enforces a same-origin CSRF check on every destructive route, so the proxy must forward the original `Host` header — both sample proxies do that out of the box.
+
+The second one is yours to add. privacytracker only honours `X-Forwarded-For` / `X-Forwarded-Host` when `PRIVACYTRACKER_TRUST_PROXY` is set, and neither `.env.example` carries it, nor do the sample `compose.yaml` files pass it into the app container. Until you add it to the `privacytracker` service's `environment:` block yourself — the root `docker-compose.yml` documents the variable inline — rate-limit keys and `audit_log.actor_ip` collapse to the literal `local`, meaning one shared rate-limit bucket per route and no usable client IP in the audit trail. See [Hardening → TLS via reverse proxy](/hardening#tls-via-reverse-proxy) for the threat-model walkthrough.
+
+The third one is what actually breaks the deployment, and it is also yours to add. `proxy.ts` rejects every request whose `Host` isn't allowlisted — `GET` included, before any other gate — with 400 `{"error":"Host not allowed"}`, and the default allowlist is loopback only. Neither sample passes `PRIVACYTRACKER_ALLOWED_HOSTS` into the app container and neither `.env.example` mentions it; the stacks work out of the box only because `PRIVACYTRACKER_HOST` defaults to `privacytracker.localhost` and `*.localhost` is treated as loopback. The moment you follow the `.env.example` comment and set a LAN DNS name, mDNS name, or a real domain, every request 400s until you add that same name to `PRIVACYTRACKER_ALLOWED_HOSTS` in the `privacytracker` service's `environment:` block.
## Verify the install
```bash
curl http://localhost:3000/api/ready
-# {"ok":true,"db":"reachable","data":"writable"}
+# {"status":"ready","checks":{...}}
```
-If you get `{"ok":false}`, check write permissions on the `data/` directory and confirm the process can open `data/privacy.db`. WAL mode plus a 5-second `busy_timeout` are set on every open, so concurrent reads while a write is in flight should never block longer than that.
+If you get `{"status":"not_ready"}` (HTTP 503), check write permissions on the `data/` directory and confirm the process can open `data/privacy.db`. WAL mode plus a 5-second `busy_timeout` are set on every open, so concurrent reads while a write is in flight should never block longer than that.
## Where the data lives
diff --git a/performance-and-sizing.mdx b/performance-and-sizing.mdx
index 3fae43b..f1a9cd2 100644
--- a/performance-and-sizing.mdx
+++ b/performance-and-sizing.mdx
@@ -18,7 +18,9 @@ Idle privacytracker (no scrape running, no users connected):
| **Disk (DB)** | see [Disk per year](#disk-per-year-of-history) | grows linearly with apps × snapshots |
| **Network egress (idle)** | zero | 30-min scheduler tick fires scrapes if `sync_schedule` is on |
-The Next.js process is the dominant memory user. better-sqlite3 keeps the DB connection open in WAL mode; the connection itself is a few MB. There's no separate background worker — the 30-minute ticker runs in the same process via `instrumentation.ts`.
+The Next.js process is the dominant memory user. better-sqlite3 keeps a DB connection open in WAL mode; the connection itself is a few MB. There's no separate service to supervise — the 30-minute ticker runs in the same process via `instrumentation.ts`.
+
+One thread does get spawned, though: on the first bulk write of a process's life, `lib/db-worker-client.ts` starts a singleton `worker_threads` writer (`lib/db-worker.cjs`) holding its *own* better-sqlite3 connection to the same `privacy.db`, serialising with the main thread on the WAL write lock. Scrapes and imports both trigger it, so it's live exactly during the ~250 MB worst case above — a second V8 isolate and a second SQLite connection, both counted in the process figures. It never starts on an idle install, which is why the idle rows are lower.
## Disk per year of history
@@ -90,7 +92,7 @@ For a typical privacy policy of ~20 KB:
privacytracker hashes policy text and skips re-summarisation when the hash is unchanged, so cosmetic edits to policies don't burn calls. The provider only sees policy text — no app names, no annotations, no telemetry. See [FAQ → How much do AI summaries cost?](/faq) for the breakdown.
-Set a hard upper bound in **Settings → AI → Daily token budget** if you want belt-and-braces cost protection.
+There's no in-app spend cap. If you want a hard upper bound, set a spending limit on the API key at OpenAI or Anthropic and use that key here — privacytracker never sees your provider's billing, so the ceiling has to live on their side.
## RAM headroom
diff --git a/quickstart.mdx b/quickstart.mdx
index e0cb397..9d7cef3 100644
--- a/quickstart.mdx
+++ b/quickstart.mdx
@@ -66,7 +66,7 @@ Whichever way you installed it, the in-app workflow is the same.
- First-run onboarding asks who you're tracking apps for (`self`, `loved one`, `guardian`) and what you want to focus on (`understand`, `declutter`, `minimal`, plus an optional `accessibility` modifier). These choices tune the UI, and a short goal-aware guided tour highlights the surfaces they unlock — dismiss it any time and replay it later from the focus tour at `/help/focus`.
+ First-run onboarding asks who you're tracking apps for (`self`, `loved one`, `guardian`) and what you want to focus on (`monitor`, `cleanup`, `minimal`, plus an optional `accessibility` modifier). These choices tune the UI, and a short goal-aware guided tour highlights the surfaces they unlock — dismiss it any time and replay it later from the focus tour at `/help/focus`.
Changed your mind? Re-pick audience and goals from the **Your focus** card at the top of **Settings** via its **Adjust** button. Individual feature flags can be fine-tuned separately under **Settings → Developer Options → Feature flags**.
@@ -112,10 +112,10 @@ Whichever way you installed it, the in-app workflow is the same.
```bash
curl http://localhost:3000/api/ready
-# {"ok":true,"db":"reachable","data":"writable"}
+# {"status":"ready","checks":{...}}
```
-`GET /api/ready` is the readiness probe (DB reachable + data dir writable). `GET /api/health` is the cheaper liveness probe used by uptime checks.
+`GET /api/ready` is the readiness probe (DB reachable + data dir writable). It returns 200 with `status: "ready"`, or 503 with `status: "not_ready"` and the failing entries in `checks` — which is what makes it usable as a Docker `HEALTHCHECK`. `GET /api/health` is the cheaper liveness probe used by uptime checks.
## Next steps
diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs
index e50fc41..c8198d4 100644
--- a/scripts/sync-changelog.mjs
+++ b/scripts/sync-changelog.mjs
@@ -76,22 +76,29 @@ async function loadSource(arg) {
return response.text();
}
+// Upstream section headings, in both shapes we've shipped:
+// Keep a Changelog (current): `## [Unreleased]`, `## [0.1.2] — 2026-06-12`
+// Legacy release.yml output: `## v1.1.0 — Title`
+// Anchored at `##` so `### Added` subsections inside an entry never match.
+const VERSION_HEADING = /^##\s+(?:\[[^\]]+\]|v\d)/i;
+
function stripUpstreamHeader(source) {
// The upstream file starts with:
//
// # Changelog
//
- // All notable changes to this project are recorded here. Sections are
- // generated automatically by `.github/workflows/release.yml` ...
+ // All notable changes to this project are documented here. The format is
+ // based on Keep a Changelog ...
//
// We replace that with our own frontmatter + intro and keep everything from
- // the first version section (`## v…`) onwards.
+ // the first version section onwards — including `[Unreleased]`, which is
+ // where in-flight work lands between releases.
const lines = source.split("\n");
- const versionStart = lines.findIndex((line) => /^##\s+v/i.test(line));
+ const versionStart = lines.findIndex((line) => VERSION_HEADING.test(line));
if (versionStart === -1) {
throw new Error(
- "Upstream CHANGELOG.md has no version section (## vX.Y.Z) — refusing to overwrite changelog.mdx blindly.",
+ "Upstream CHANGELOG.md has no version section (## [X.Y.Z] or ## vX.Y.Z) — refusing to overwrite changelog.mdx blindly.",
);
}
diff --git a/security.mdx b/security.mdx
index 2981d63..e80b0dc 100644
--- a/security.mdx
+++ b/security.mdx
@@ -16,6 +16,8 @@ What privacytracker protects against:
- **Silent privacy-label changes.** The whole point. Snapshots + diffs + notifications surface when an app's disclosed data collection changes without you noticing.
- **Cross-origin abuse of the local API.** Same-origin CSRF check on every mutating verb. A malicious tab can't trigger `POST /api/scrape` against your local install.
- **Casual access to destructive routes when the API is exposed beyond localhost.** The optional `AUDITOR_ADMIN_TOKEN` adds a shared-secret layer on top of CSRF.
+- **Brute-forcing the admin token.** Token login is capped at 5 attempts per minute, plus an absolute backstop that trips after 100 failed attempts in 15 minutes no matter where they come from. Comparison is constant-time. See [Rate limiting](#rate-limiting) for what the per-caller bucket is actually keyed on.
+- **DNS rebinding.** Every request — including `GET` — is rejected with `400 Host not allowed` unless its effective `Host` is on the allowlist, which defaults to loopback only.
- **Silent ed25519 signature stripping during desktop auto-update.** The Tauri updater verifies every patch's signature before applying it.
- **Force-include of private annotations in audit-bundle exports.** SQL-level filter at build time; no URL parameter or escape hatch can override it.
- **Unwitting AI provider exfiltration.** AI is opt-in and disabled by default. When enabled, only privacy-policy text is sent — never app names, your annotations, or telemetry of any kind.
@@ -24,13 +26,13 @@ What privacytracker does *not* protect against:
- **A malicious or compromised host machine.** If your laptop is rooted, privacytracker can't protect data inside it. Use full-disk encryption.
- **A compromised AI provider.** When you enable an AI provider and send privacy-policy text to it, that text leaves your machine. We hash policies and skip re-summarisation when the hash is unchanged, but the content of policies is still visible to whichever provider you chose.
-- **Volumetric DoS or brute-force against a self-hosted instance.** privacytracker has no rate limit of its own. If you expose it to the public internet, put it behind a rate-limiting reverse proxy.
+- **Volumetric DoS from many source IPs.** privacytracker does rate-limit its own routes ([Rate limiting](#rate-limiting)), but that limiter is defence-in-depth: the thresholds are modest, and unless you set `PRIVACYTRACKER_TRUST_PROXY` every caller lands in one shared bucket per route. It will not absorb a distributed flood. If you expose the instance to the public internet, put it behind a rate-limiting reverse proxy.
- **Multi-user separation.** There are no user accounts. Anyone with browser access to your instance has full access to its data.
- **Physical attacks against your machine.** Out of scope.
## What data leaves your device
-Three things, all opt-in or transparent:
+Four things, all opt-in or transparent:
@@ -54,32 +56,52 @@ Three things, all opt-in or transparent:
Local providers (Ollama, llama.cpp, LM Studio, vLLM) keep all of this on your machine.
+
+ If you paste an incoming-webhook URL into the desktop app's *Keep privacytracker running in the background* wizard (or write `notification_webhook_url` through `POST /api/settings` on any build), privacytracker POSTs notifications to that URL. Off by default — an empty URL, or `notification_webhook_frequency = off`, disables it entirely.
+
+ The receiving service sees **app names and change summaries** — e.g. *"Instagram: 2 new categories under Data Used to Track You"*. It does not see your annotations, privacy profile, AI keys, or the underlying policy text. Four payload shapes are supported (`slack`, `discord`, `teams`, `generic`); `generic` additionally carries the structured rows under `notifications`.
+
+ Delivery is either immediate (on each new notification) or a daily/weekly batch posted from the 30-minute tick. Requests go out through the same `safeFetch` SSRF, size, and timeout guards as `/api/scrape`. Since this is a third-party chat service, treat it as you would any other outbound integration: pick a private channel.
+
-privacytracker has **no analytics, no telemetry, no crash reporting** of its own. The SQLite database, all settings, all your annotations — everything stays on your machine.
+privacytracker has **no analytics, no telemetry, no crash reporting** of its own. Beyond the four paths above, the SQLite database, all settings, all your annotations stay on your machine.
## Authentication & access control
-Two layers, applied to *destructive* routes only. Read-only endpoints have neither.
+Three layers. The host allowlist applies to every request; the CSRF check applies to mutations; the admin token applies to mutations and — on a network-exposed instance — to a short list of sensitive reads too.
+
+### Host allowlist (always on)
+
+Before anything else, `proxy.ts` rejects any request — `GET` included — whose effective `Host` isn't allowlisted:
+
+```bash
+curl -H "Host: evil.example" http://localhost:3000/api/apps
+# 400 { "error": "Host not allowed" }
+```
+
+The default allowlist is loopback only. Add LAN hostnames with `PRIVACYTRACKER_ALLOWED_HOSTS` — see [Configuration → Network exposure](/configuration#network-exposure). This is the DNS-rebinding defence: browsers can't spoof `Host`, so a page that rebinds DNS to your loopback instance still arrives under its own hostname and is bounced.
### Same-origin CSRF check (always on)
-Every mutating verb (`POST`, `PUT`, `PATCH`, `DELETE`) checks that the `Origin` header matches the request host. The check lives in `proxy.ts` and runs before any route handler.
+Every mutating verb (`POST`, `PUT`, `PATCH`, `DELETE`) on `/api/` checks that the `Origin` header matches the request host, unless the caller supplies the admin token instead. The check lives in `proxy.ts` and runs before any route handler.
```bash
curl -X POST http://localhost:3000/api/reset
-# 403 { "error": "origin_mismatch" }
+# 403 { "error": "Cross-origin mutation rejected" }
curl -X POST http://localhost:3000/api/reset \
-H "Origin: http://localhost:3000"
# 200
```
+The body is human-readable prose, not a stable machine code — match on the status, not the string.
+
Browser requests from your installed UI satisfy this automatically. Cross-origin or curl-from-a-different-host calls won't unless they set the header explicitly. If you're behind a reverse proxy, the proxy must forward the original `Host` header — see [Troubleshooting → Reverse-proxy CSRF rejection](/troubleshooting#reverse-proxy-csrf-rejection).
### Admin token (optional, opt-in)
-When you set `AUDITOR_ADMIN_TOKEN` in the environment, *destructive* routes (`POST /api/reset`, `DELETE /api/apps`, `POST /api/settings`, `DELETE /api/wayback/import-all`, etc.) require an `X-Auditor-Admin-Token` header on top of the CSRF check.
+When you set `AUDITOR_ADMIN_TOKEN` in the environment, *destructive* routes (`POST /api/reset`, `DELETE /api/apps`, `POST /api/settings`, `DELETE /api/wayback/import-all`, etc.) require the token on top of the CSRF check. Calls without it get **401** `{ "error": "Admin token required" }`.
```bash
export AUDITOR_ADMIN_TOKEN=$(openssl rand -hex 32)
@@ -98,37 +120,96 @@ The token is purely opt-in — there's no built-in user/account system in privac
Generate a token with `openssl rand -hex 32`. Store it like any other secret — never commit it, never paste it into a public log.
+#### Sensitive reads are gated too
+
+Once the instance is network-exposed — any non-loopback entry in `PRIVACYTRACKER_ALLOWED_HOSTS`, `PRIVACYTRACKER_NETWORK_EXPOSED` set, or a specific non-loopback `PRIVACYTRACKER_BIND_HOST` — the token is also required on `GET` requests whose path starts with any of:
+
+```
+/api/ai/debug-log
+/api/backup/
+/api/deployment/
+/api/desktop/diagnostics
+/api/diagnostics/
+/api/export
+/api/import/
+```
+
+Missing or wrong token gets 401 `{ "error": "Admin token required for non-local API access" }`. This proxy gate never fires on a loopback-only install — but it isn't the only gate. `GET /api/backup/export` and `GET /api/ai/debug-log` check `adminTokenRequiredForRequest()` themselves, which is `adminTokenConfigured() || isNetworkExposed()` (`lib/security.ts`), so they return 401 `{ "error": "Admin token required" }` as soon as `AUDITOR_ADMIN_TOKEN` is set, loopback or not. This is why "read-only means unauthenticated" doesn't hold for the LAN deployments the token exists for.
+
+#### Browser sessions use a cookie, not the header
+
+The Settings panel can't put a long-lived secret in JavaScript, so it exchanges the token once and works from a cookie afterwards:
+
+| Endpoint | Does |
+|---|---|
+| `POST /api/auth/admin-token/login` | Takes `{ "token": "..." }`, constant-time compares, sets `pt_admin_token` — HttpOnly, `SameSite=strict`, `Secure` over HTTPS, 8-hour max-age. |
+| `POST /api/auth/admin-token/logout` | Clears the cookie server-side (JS can't, it's HttpOnly). |
+| `GET /api/auth/admin-token/status` | Returns `{ configured, unlocked }`. Never returns the token. |
+
+All three are exempt from the non-local admin gate (`ADMIN_AUTH_BYPASS_PREFIX` in `proxy.ts`) — the login endpoint is how you obtain the cookie in the first place, so gating it would be circular. Same-origin is the narrower claim: login and logout call `isSameOriginRequest()` themselves, but `GET .../status` doesn't, and the proxy's CSRF check only covers `POST`/`PUT`/`PATCH`/`DELETE` — so a cross-origin `GET` reaches that handler. What it gets back is two booleans; the token itself is never returned. `proxy.ts` treats the cookie and the `X-Auditor-Admin-Token` header as equivalent everywhere else.
+
+Keeping the token out of `sessionStorage` is the point: an injected script can't read an HttpOnly cookie, so an XSS bug can't exfiltrate the token and replay it later from somewhere else.
+
+### Rate limiting
+
+privacytracker rate-limits its own routes. `checkRateLimit()` in `lib/security.ts` is a per-key sliding window, keyed on route prefix plus client IP, applied to 56 of the 110 API routes — 33 calling it directly, 23 more through `requireMutationGuard()`, which also writes a `.rate_limited` audit row. Every denial is a 429, but only some carry `Retry-After`: `requireMutationGuard()` always sets it (`lib/api-guards.ts`), as do a handful of direct callers such as `/api/scrape`, `/api/search`, and the admin-token login. Most routes that call `checkRateLimit()` directly — `/api/settings`, `/api/reset`, `/api/backup/export`, `/api/backup/restore`, `/api/shortlist`, the `/api/manual-apps` family — return a bare 429 with just the error body, so a client that backs off on the header needs a fallback for those. None of it is behind an env var or build mode.
+
+| Route | Limit |
+|---|---|
+| `POST /api/scrape` | 30 / minute |
+| `POST /api/search` | 60 / minute |
+| `POST /api/reset` | 30 / 10 minutes |
+| `POST /api/backup/restore` | 3 / 10 minutes |
+| `POST /api/auth/admin-token/login` | 5 / minute |
+| Failed admin-token logins, all sources | 100 / 15 minutes |
+
+Read the boundary carefully. Without `PRIVACYTRACKER_TRUST_PROXY`, `X-Forwarded-For` is attacker-controlled and deliberately ignored, so the IP part of every key collapses to the constant `local` — one shared bucket per route rather than one per caller. That's what the last row is for: an absolute, IP-independent backstop on failed logins, counting failures only, so an attacker who trips it inflicts a self-healing cooldown rather than locking you out.
+
+The practical consequence: these limits stop a runaway client loop and make token brute-force impractical. They are not a substitute for a rate-limiting reverse proxy in front of an internet-facing instance.
+
+
+ A 429 from privacytracker is not the same thing as a 429 from Apple. The internal limiter logs its denials with the marker `This is our INTERNAL limiter (lib/security.ts), not Apple's 429 cooldown.` and clears within its own window; Apple's cooldown is longer and shows up as `partial: rateLimited` in the sync runner.
+
+
### Audit log
Every failed admin-token attempt (and a handful of other security-relevant events) is appended to the `audit_log` table:
| Column | Contents |
|---|---|
-| `id` | autoincrement |
-| `at` | Unix timestamp (ms) |
-| `event` | event code (`admin_token_failed`, `admin_token_succeeded`, `reset_invoked`, `bundle_restored`, `bundle_exported`, etc.) |
-| `ip` | requester IP (from `x-forwarded-for` if behind a proxy, else direct) |
-| `user_agent` | the `User-Agent` header verbatim |
-| `details` | JSON blob with route + non-sensitive context |
+| `id` | UUID (`crypto.randomUUID()`), TEXT primary key — not an autoincrement integer |
+| `created_at` | Unix timestamp (ms) |
+| `action` | dot-namespaced action code — `admin_token.login`, `admin_token.login.invalid`, `reset.success`, `backup.export.success`, `app.delete.success`, … |
+| `actor_ip` | requester IP — from `x-forwarded-for` only when `PRIVACYTRACKER_TRUST_PROXY` is set, otherwise the literal `local` |
+| `user_agent` | the `User-Agent` header, truncated to 256 chars |
+| `detail` | free-text context, truncated to 1024 chars |
+| `success` | `1` or `0` |
-The table is **append-only** by convention — no API route writes a `DELETE` against it, and the schema has no `deleted_at` column. If you want to prune it for storage, drop and recreate manually with the app stopped.
+Action codes are namespaced by facility, then outcome. A failed admin-token login is `admin_token.login.invalid` (with `admin_token.login.rate_limited` and `admin_token.login.global_throttled` for the two throttle paths); a successful one is `admin_token.login`. A reset is `reset.success`, `reset.unauthorised`, `reset.rate_limited`, or `reset.failed`. Bundle export is `backup.export.success`; restore is `backup.restore.*`.
-Review it from the UI under **Settings → Diagnostics → Audit log**, or directly:
+Rows are only ever appended — the schema has no `deleted_at` column and nothing deletes or rewrites an individual entry. The table as a whole is not protected, though: `audit_log` is listed in `START_OVER_TABLES_TO_TRUNCATE` (`lib/reset-tables.ts`), so `POST /api/admin/start-over` runs `DELETE FROM audit_log`, and `POST /api/backup/restore` clears it too before replacing it with whatever the bundle carried (`TABLES_IN_INSERT_ORDER` in `lib/backup.ts`). `POST /api/reset` is the destructive route that deliberately preserves it. Both wipe paths need the admin token — which means an actor who has the token can erase the record of their own access, so copy the log off-host if you need it to survive that. If you want to prune it for storage, drop and recreate manually with the app stopped.
+
+There is no audit-log UI. Nothing in the app reads this table — no route SELECTs from it, and there's no Settings panel for it. It's a forensic trail, not a screen. Read it with the sqlite3 CLI:
```bash
-sqlite3 data/privacy.db "SELECT datetime(at/1000, 'unixepoch'), event, ip FROM audit_log ORDER BY at DESC LIMIT 50;"
+sqlite3 data/privacy.db "SELECT datetime(created_at/1000, 'unixepoch'), action, actor_ip, success FROM audit_log ORDER BY created_at DESC LIMIT 50;"
```
+The separate `activity_log` table — scrapes, re-syncs, scheduled runs, migrations, backup/restore — *is* surfaced in the UI, under **Settings → Admin → Developer Options → Activity log**. Don't confuse the two: `activity_log` is the operational timeline, `audit_log` is the privileged-request trail.
+
## Cryptographic primitives
| Use | Primitive | Where |
|---|---|---|
-| Admin-token verification | `crypto.timingSafeEqual` (Node native) | `proxy.ts` |
+| Admin-token verification | `crypto.timingSafeEqual` (Node native) | `proxy.ts`, `lib/security.ts` |
| Privacy-policy version hashing | SHA-256 | `lib/privacy-policy.ts` |
| Desktop-app auto-update verification | ed25519 signature on every patch | Tauri updater |
-| Backup-bundle integrity | none today (versioned JSON only) | `lib/audit-bundle.ts` |
+| Backup-bundle authenticity | HMAC-SHA256 over the canonicalised envelope, verified in constant time | `lib/backup.ts` |
+| Audit-bundle integrity | none today (versioned JSON only) | `lib/audit-bundle.ts` |
-Backup bundles are intentionally unencrypted JSON so they're inspectable and recoverable without tooling. Treat them as sensitive data and share via channels you'd use for any other personal file — see [Audit-bundle export threat model](#audit-bundle-export-threat-model) below.
+Backup bundles are signed, not encrypted. The key is 32 random bytes at `/backup-signing.key`, mode `0600`, generated on first use and never leaving the machine — so a signature proves *"this bundle came from this install"*, nothing more. A bundle exported anywhere else fails verification and needs the explicit untrusted opt-in to restore; see [Backup & restore](/backup-and-restore#restoring-a-bundle-from-another-install). Anyone who can read that key file can forge envelopes targeting your install.
+
+Both bundle kinds are unencrypted JSON so they're inspectable and recoverable without tooling. Treat them as sensitive data and share via channels you'd use for any other personal file — see [Audit-bundle export threat model](#audit-bundle-export-threat-model) below.
## Verifying signed binaries
@@ -150,7 +231,9 @@ To verify the build matches source, [build from source](/develop/build-from-sour
## Audit-bundle export threat model
-The `audience: loved_one` workflow lets users export an audit bundle — a JSON file describing tracked apps, their privacy labels, AI summaries, and exportable annotations. Bundles can also include the privacy profile you assessed against, with an opt-out checkbox in the export dialog.
+An audit bundle is a JSON file describing tracked apps, their privacy labels, AI summaries, and exportable annotations. Bundles can also include the privacy profile you assessed against, with an opt-out checkbox in the export dialog.
+
+`POST /api/export/audit-bundle` allows the export when the `flag.settings.admin.export.audit_bundle` flag resolves to `on`, **or** when your focus workflow (`flag.focus.workflow`) is `other_handoff` — the "I'm preparing a bundle for someone else" answer. Either arm is sufficient, and the first one is what a recommender trips: `AUDIENCE_RULES.loved_one` in `lib/feature-flag-rules.ts` sets that flag to `on`, so picking the **Loved one** audience unlocks the export by itself, with the workflow still sitting at `custom`. For `self` and `guardian` the hard default is `off`, and those audiences need the `other_handoff` answer or an explicit flag override before the route stops returning 403. The API is the authoritative gate; client surfaces just hide the button.
**What's included** (when you click Export):
diff --git a/troubleshooting.mdx b/troubleshooting.mdx
index d08fac5..271a9e9 100644
--- a/troubleshooting.mdx
+++ b/troubleshooting.mdx
@@ -3,7 +3,7 @@ title: Troubleshooting
description: "Common issues self-hosters hit, with diagnostics and fixes."
---
-If you don't see your problem here, the support bundle has more context: Settings → Diagnostics → Copy support bundle. Paste it into a [GitHub issue](https://github.com/privacykey/privacytracker/issues).
+If you don't see your problem here, the support bundle has more context: Settings → Admin → Deployment Diagnostics → Copy support bundle. Paste it into a [GitHub issue](https://github.com/privacykey/privacytracker/issues).
## App Store labels stopped parsing
@@ -70,21 +70,22 @@ docker compose up --build -d
If you're running on SELinux (e.g. Fedora), add `:Z` to the volume mount in `docker-compose.yml` so the host directory gets the right context.
-## `/api/ready` returns `{"ok":false}`
+## `/api/ready` returns `not_ready`
**Diagnose:**
```bash
-curl http://localhost:3000/api/ready
-# {"ok":false,"db":"unreachable","data":"writable"}
+curl -i http://localhost:3000/api/ready
+# HTTP/1.1 503 Service Unavailable
+# {"status":"not_ready","checks":{...}}
```
-Two things are checked: SQLite reachability and `data/` writability. The error tells you which failed.
+A ready instance answers 200 with `{"status":"ready"}`. Two things are checked: SQLite reachability and data-directory writability. The `checks` object names which failed.
| Failure | Meaning | Fix |
|---|---|---|
-| `db: unreachable` | The process can't open `data/privacy.db` | Check `data/privacy.db.lock` isn't held by a stale process; `chmod 644 data/privacy.db`; on Docker, confirm the volume mounted (`docker compose exec app ls -la /app/data`) |
-| `data: not writable` | The data directory itself isn't writable | `chmod 755 data`; on Docker, confirm the volume isn't mounted read-only |
+| Database unreachable | The process can't open `privacy.db` | Check no stale process holds the file; `chmod 644 data/privacy.db`; on Docker, confirm the volume mounted (`docker compose exec app ls -la /app/data`) |
+| Data directory not writable | The data directory itself isn't writable | `chmod 755 data`; on Docker, confirm the volume isn't mounted read-only |
If you've been running with WAL mode (the default) and recently force-killed the process, you may have a stale `-wal` or `-shm` file. They're safe to delete with the app stopped.
@@ -92,7 +93,7 @@ If you've been running with WAL mode (the default) and recently force-killed the
**Symptoms:** AI provider is configured, the test in **Settings → AI → Test connection** passes, but summaries on app detail pages stay empty.
-**Diagnose:** Open **Settings → AI → Debug log**. Recent calls show provider, model, prompt size (chars), response status, and timing.
+**Diagnose:** Turn on *Record AI prompts and responses*, then open the AI debug log under **Settings → Admin → Developer Options**. Recent calls show provider, model, prompt size (chars), response status, and timing.
| Symptom in the log | Cause | Fix |
|---|---|---|
@@ -106,7 +107,7 @@ For local models, the `providerLikelyNeedsChunking` flag automatically splits do
## Reverse-proxy CSRF rejection
-**Symptoms:** Self-hosted behind Caddy/Traefik/Nginx; reads work but every mutation (`POST`, `PUT`, `DELETE`) returns 403 with `{"error":"origin_mismatch"}`.
+**Symptoms:** Self-hosted behind Caddy/Traefik/Nginx; reads work but every mutation (`POST`, `PUT`, `DELETE`) returns 403 with `{"error":"Cross-origin mutation rejected"}`.
**Cause:** privacytracker enforces a same-origin CSRF check on every destructive route by comparing `Origin` against `Host`. If your proxy rewrites either header, the check fails.
@@ -125,7 +126,9 @@ Sample Traefik label:
- "traefik.http.middlewares.privacytracker-headers.headers.customRequestHeaders.Host=privacytracker.local"
```
-Full samples are on the [privacytracker Plane board](https://sites.plane.so/issues/39b6604351894f09a5e903acce37d265).
+Complete Compose stacks ship in the main repo under `deploy/caddy/` and `deploy/traefik/` — start from those if you have a checkout.
+
+**If reads fail too,** with 400 `{"error":"Host not allowed"}`, this is a different check: the Host allowlist, which defaults to loopback only. Add your proxy's hostname to `PRIVACYTRACKER_ALLOWED_HOSTS` — see [Configuration → Network exposure](/configuration#network-exposure).
## Wayback import: "skipped, no capture"
@@ -141,13 +144,14 @@ Full samples are on the [privacytracker Plane board](https://sites.plane.so/issu
**Symptoms:** App boots into an error screen reading "Migration step `` failed: …" with an Attempt counter and a Try again button.
-**Diagnose:** The error message names the failing step. The five steps run in order and each is idempotent:
+**Diagnose:** The error message names the failing step. The six steps run in order and each is idempotent:
-1. `schema` — `CREATE TABLE` for `feature_flag_overrides`
-2. `user_intent_to_focus` — map old `user_intent` to new focus keys
+1. `schema_check` — verify `feature_flag_overrides` and the other new tables exist
+2. `user_intent_migration` — map old `user_intent` to new focus keys
3. `notification_prefs_absorb` — flatten the old JSON blob into per-type rows
4. `callout_rename` — drop stale override rows for renamed keys
5. `quarantine_check` — flag overrides whose keys are unknown to this version
+6. `focus_goal_rename` — move `flag.focus.goal.understand` / `.declutter` onto `.monitor` / `.cleanup`
**Fix:** Tap **Try again** — most failures are transient (file lock, slow disk). Up to 3 retries are offered; after that, the screen surfaces a **Reset DB** option that wipes `data/privacy.db` and routes you to onboarding. Take a backup first if you have anything you don't want to lose:
@@ -166,7 +170,7 @@ cp data/privacy.db data/privacy.db.before-reset
1. **Reset one flag.** In **Settings → Developer Options → Feature flags**, find the flag and clear its override. It falls back to the computed default for your current focus.
2. **Flip the kill-switch.** If several flags are tangled, set `flag.devopts.feature_flag_system.enabled` to **off** on the same Developer Options screen. This collapses *every* flag to its hard default without touching your focus, apps, or notes. Turning it back **on** re-engages your audience/goals and any overrides — no data is lost either way.
-If the layout is still wrong after the kill-switch, it isn't a flag problem — grab the support bundle (**Settings → Diagnostics**) and open an issue.
+If the layout is still wrong after the kill-switch, it isn't a flag problem — grab the support bundle (**Settings → Admin → Deployment Diagnostics**) and open an issue.
## Sync stuck "running"
@@ -202,9 +206,11 @@ Then restart. This is safe — the worst case is one app's worth of duplicate wo
**Cause:** Apple rate-limited the IP. The runner is tuned to bail cleanly on the first 429, record a `partial` activity row, and clear state + mutex so the next 30-minute scheduler tick can retry fresh.
-**Fix:** Wait 30+ minutes and the scheduled tick will retry automatically. If you hit 429 repeatedly, reduce sync frequency in **Settings → Sync** (e.g., daily instead of every 30 minutes) or stagger your apps across multiple runs.
+**Fix:** Wait 30+ minutes and the scheduled tick will retry automatically. If you hit 429 repeatedly, switch **Settings → Sync → Schedule** from `daily` to `weekly`, or stagger your apps across multiple manual runs. `daily` is the fastest cadence there is — the 30-minute figure is the ticker interval, not a schedule option.
+
+If the 429s keep coming, set the schedule to `manual`. A rate-limited exit deliberately doesn't stamp `last_auto_sync`, so the run stays "due" and the ticker retries every 30 minutes regardless of whether you picked `daily` or `weekly`. Only `manual` stops that loop.
## Where to ask for help
-- **GitHub Issues:** [github.com/privacykey/privacytracker/issues](https://github.com/privacykey/privacytracker/issues) — bug reports and feature requests use the `bug_report.yml` template and benefit from a copied support bundle (**Settings → Diagnostics**)
+- **GitHub Issues:** [github.com/privacykey/privacytracker/issues](https://github.com/privacykey/privacytracker/issues) — bug reports and feature requests use the `bug_report.yml` template and benefit from a copied support bundle (**Settings → Admin → Deployment Diagnostics**)
- **Security:** [GitHub Private Vulnerability Reporting](https://github.com/privacykey/privacytracker/security/advisories/new) — never open a public issue for a security finding
diff --git a/upgrading.mdx b/upgrading.mdx
index c7fe877..10099ca 100644
--- a/upgrading.mdx
+++ b/upgrading.mdx
@@ -51,14 +51,14 @@ This page covers the version-agnostic process. For per-release notes — what sp
`instrumentation.ts` writes one activity row per migration step. The aggregate row at the end looks like:
```
- migration_v1_completed: 5/5 steps, total: 412ms
+ migration_v1_completed: 6/6 steps, total: 412ms
```
- From the UI: **Settings → Diagnostics → Activity log → filter `migration_*`**. From the CLI:
+ From the UI: **Settings → Admin → Developer Options → Activity log**, filtered to *Migration*. From the CLI:
```bash
sqlite3 data/privacy.db \
- "SELECT datetime(at/1000, 'unixepoch'), kind, details FROM activity WHERE kind LIKE 'migration_%' ORDER BY at DESC LIMIT 20;"
+ "SELECT datetime(started_at/1000, 'unixepoch'), summary, detail FROM activity_log WHERE type = 'migration' ORDER BY started_at DESC LIMIT 20;"
```
If the aggregate row says *N/N steps*, you're done.
@@ -66,7 +66,7 @@ This page covers the version-agnostic process. For per-release notes — what sp
```bash
curl http://localhost:3000/api/ready
- # {"ok":true,"db":"reachable","data":"writable"}
+ # {"status":"ready","checks":{...}}
```
Click through a few app detail pages, the dashboard, the bell. If anything looks wrong, see [If a migration fails](#if-a-migration-fails) below.
@@ -80,8 +80,9 @@ That's the happy path. It's been the entire process for every upgrade so far.
`instrumentation.ts` runs serially before the app accepts requests:
1. **Schema migrations.** Every `CREATE TABLE IF NOT EXISTS` runs (no-op for existing tables) plus the inline `migrations` array of `ALTER TABLE` statements. Each migration is idempotent — running it twice is safe.
-2. **Bulk-runner state checks** (3 staggered: 8s, 10s, 12s after boot). For each of `sync_running`, `wayback_import_running`, `policy_sync_running`: no-op, heal a stale lock, or auto-resume from a saved state blob with `initiator: 'resume'`.
-3. **Quarantine sweep.** Any `feature_flag_overrides` row whose key isn't in this build's `FlagKey` union gets `quarantined = 1`; rows previously quarantined whose keys are back get cleared. This is what makes downgrades-then-upgrades not lose your overrides.
+2. **Feature-flag migration** — six ordered, idempotent steps ending with the focus-goal rename. See [Feature flags → Migration](/develop/feature-flags).
+3. **Bulk-runner state checks** (3 staggered: 8s, 10s, 12s after boot). For each of `sync_running`, `wayback_import_running`, `policy_sync_running`: no-op, heal a stale lock, or auto-resume from a saved state blob with `initiator: 'resume'`.
+4. **Quarantine sweep.** Any `feature_flag_overrides` row whose key isn't in this build's `FlagKey` union gets `quarantined = 1`; rows previously quarantined whose keys are back get cleared. This is what makes downgrades-then-upgrades not lose your overrides.
None of these block the UI. If migrations fail, the boot sequence renders an error screen instead of the normal app — see [If a migration fails](#if-a-migration-fails).
@@ -89,14 +90,19 @@ None of these block the UI. If migrations fail, the boot sequence renders an err
The activity log is the canonical record of what happened during an upgrade. Useful filters when investigating:
-| Filter | Surfaces |
+Rows live in `activity_log`, with the category in `type` and the detail in `summary`. Useful slices when investigating:
+
+| `type` | Surfaces |
|---|---|
-| `migration_*` | All migration step events plus the aggregate. |
-| `wayback_resumed` / `sync_resumed` / `policy_resumed` | Auto-resumed bulk runs. |
-| `*_stale_cleared` | A mutex was held with no queue and got healed. |
-| `bundle_restored` | Someone hit `POST /api/backup/restore`. Watch this column for unexpected entries. |
+| `migration` | Every migration step plus the `migration_v1_completed` aggregate, all in `summary`. |
+| `scheduled_sync` / `manual_sync` | Bulk sync runs. A boot-time auto-resume records as `scheduled_sync`; its `summary` ends with *(resumed after restart)*. |
+| `wayback_import` | Wayback back-fill runs. |
+| `backup_restore` | Someone hit `POST /api/backup/restore`. Watch for unexpected entries. |
+| `backup_export` / `reset` | Bundle exports and resets. |
+
+Stale-mutex heals and resumes also raise a bell notification — those carry `sync_stale_cleared` / `sync_resumed` and their wayback and policy equivalents in the **notifications** table, not here.
-The log is append-only by convention — `app/api/*/route.ts` doesn't expose a delete. If it grows large, drop and recreate manually with the app stopped.
+The log is capped at the most recent 2,000 events, pruned inside `recordActivity` on every write (`ACTIVITY_RETENTION` in `lib/activity.ts`). No route deletes an individual entry, but the whole table does get wiped: `activity_log` is in `APP_DATA_TABLES_TO_TRUNCATE` (`lib/reset-tables.ts`), so both `POST /api/admin/start-over` and `POST /api/dev/wipe-apps` issue `DELETE FROM activity_log`.
## If a migration fails
@@ -108,11 +114,12 @@ The boot sequence renders an error screen with the failing step name, an attempt
| Step | Meaning |
|---|---|
- | `schema` | A `CREATE TABLE` or `ALTER TABLE` failed — usually disk-full or a locked DB. |
- | `user_intent_to_focus` | Mapping legacy `user_intent` to new focus keys (one-time, v1.1 only). |
+ | `schema_check` | A `CREATE TABLE` or `ALTER TABLE` failed — usually disk-full or a locked DB. |
+ | `user_intent_migration` | Mapping legacy `user_intent` to new focus keys (one-time). |
| `notification_prefs_absorb` | Flattening the old JSON blob into per-type rows. |
| `callout_rename` | Dropping override rows for renamed flags. |
| `quarantine_check` | Quarantining unknown flag keys. |
+ | `focus_goal_rename` | Moving `flag.focus.goal.understand` / `.declutter` onto `.monitor` / `.cleanup`. |
Migrations are idempotent. Up to 3 retries are offered.
@@ -139,7 +146,7 @@ The boot sequence renders an error screen with the failing step name, an attempt
privacytracker supports forward migrations only. There's no built-in *downgrade* path because:
- Schema migrations are non-reversible without losing data added by the new version.
-- The bundle format is forward-compatible (`v1.0` bundle → `v1.1` runtime works) but not backward-compatible (`v1.1` bundle → `v1.0` runtime refuses with `schema_mismatch`).
+- The bundle format is forward-compatible — an older bundle format restores into a newer runtime — but not backward-compatible. A bundle whose integer `version` exceeds what the running release supports is refused outright, with a message telling you to upgrade.
If you need to roll back: