# Configuration
Source: https://docs.privacytracker.privacykey.org/configuration

AI providers, environment variables, background sync, notifications and webhooks, Wayback import, and the admin token.

privacytracker stores almost all configuration in the SQLite `app_settings` key/value table, which means most settings are managed through the in-app **Settings** UI rather than `.env` files. Only a few values need to live outside the database.

## Environment variables

| Variable | Default | Purpose |
|---|---|---|
| `AUDITOR_ADMIN_TOKEN` | (unset) | Shared secret. When set, destructive routes (`POST /api/reset`, `DELETE /api/apps`, `POST /api/settings`) require an `X-Auditor-Admin-Token` header verified with `crypto.timingSafeEqual`. Failed attempts are logged to `audit_log` with IP + user agent. Optional on a loopback-only install; **mandatory** once the instance is network-exposed (see below). |
| `PRIVACYTRACKER_DATA_DIR` | `<cwd>/data` | Absolute path to the data directory holding `privacy.db`. Honoured unconditionally — the Tauri shell injects it for the desktop build, and it's the escape hatch for a custom Docker mount. Created on demand at mode `0700`. |

### Network exposure

privacytracker's default posture is **loopback-only**, and it enforces that by
checking the request's `Host` header against an allowlist. Deploying it
anywhere other than `127.0.0.1` means setting these — without them a LAN
browser cannot load the app at all.

| Variable | Default | Purpose |
|---|---|---|
| `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` | (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. |

> **Warning**
>
> Trust is derived from deployment config, never from request headers — a
> `Host: localhost` from a LAN attacker cannot downgrade the instance to
> "local". `lib/deployment-trust.ts` is the single source of truth, and it's
> read fresh on every call.

### Build and runtime

| Variable | Default | Purpose |
|---|---|---|
| `PRIVACYTRACKER_RUNTIME` | (unset) | Set to `desktop` by the desktop app's Tauri shell. Gates desktop-only surfaces and feature-flag resolution. |
| `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). |
| `PRIVACYTRACKER_BACKEND` | `rust` | Docker Compose only, read from `.env`: which server the image is built with (the Dockerfile's `BACKEND` build argument). `node` builds the Node image, which stays buildable as the rollback until v0.3.0 has shipped. |

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

`lib/ai-config.ts` is the single source of truth. Four provider modes:

**disabled**

No AI calls are made. Privacy-policy text is still followed and hashed, but no `summary_json` is generated.

**openai**

OpenAI Chat Completions. Set the model in **Settings → AI → Model**. The provider check (`providerRequiresApiKey`) blocks saving without a key.

**anthropic**

Anthropic Messages API. Same UI, different default model.

**custom**

Any OpenAI-compatible endpoint — Ollama, llama.cpp, LM Studio, vLLM. The legacy `ollama` provider value is normalised to `custom` automatically. For local models, `providerLikelyNeedsChunking` triggers the ~12k-character splitter in `lib/privacy-policy.ts` so long policies don't blow the context window.

![AI provider settings](https://docs.privacytracker.privacykey.org/images/settings-ai.png)

*AI provider settings*

### Local Ollama setup

```bash
ollama pull llama3.2
ollama serve

# In Settings → AI:
# Provider:  Own Model
# Base URL:  http://localhost:11434
# Model:     llama3.2
```

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

Two knobs:

| Setting | Where | Notes |
|---|---|---|
| `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.

## Notifications

The bell reports privacy-label changes out of the box. Privacy-policy changes are off by default, behind **Privacy policy updates**: the `flag.notifications.types.policy_updates` feature flag.

Every privacy-policy fetch leaves a row on the app's History timeline (the **Change History** tab on its detail page), whatever the flag says. That covers the first capture, an unchanged rescrape, a failed or unusable fetch, and a text change with its diff. Only a text change can go further, and only while the flag is on:

| Privacy policy updates | When a policy's text changed since the last sync |
|---|---|
| Off (default) | The change stays on the History timeline with its diff, without a review marker, bell notification or webhook post. |
| On | The change is also flagged for review (the grid's pending dot, the review panel, triage and the universal changelog) and raises a bell notification, which is also what the [webhook](#notification-webhooks) reads. |

A failed or unusable fetch keeps the last policy text and hash that were actually read, so the next good fetch of the same text reads as unchanged rather than changed. The AI Policy tab's banner for a recently changed policy has its own setting, **Settings → Privacy policies & AI → Privacy Policy Change Alerts**, and shows either way.

To turn policy changes on, tick **Privacy policy updates** under **Settings → Notifications**. Upgrading keeps an override you already had. Without one, an upgraded install takes the default, so its policy events stop showing as changes to review.

Saving that section also clears any override you set under **Settings → Developer Options → Feature flags** on `flag.notifications.types.accessibility_changes` or `flag.notifications.types.new_privacy_types`, because neither has a checkbox there.

## 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` (**Each change** in the wizard) posts each App Store or privacy-policy change as soon as it's saved. The summaries post the 50 most recent notifications raised since the previous summary, read or unread, 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. |

With `immediate`, a sync or rescrape that finds an app's privacy labels, accessibility labels or age rating changed posts as soon as it has saved the change, with the first change as the headline. A privacy-policy change posts as it's raised, and only while [Privacy policy updates](#notifications) is on. Quiet hours hold back the bell notification, not the post. The bell's other notices, such as resumed runs and finished imports, reach the webhook only through the daily and weekly summaries.

The webhook doesn't apply the bell's per-type filters. A change the bell leaves out, such as an accessibility-label change when your focus doesn't include accessibility, is still posted.

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](https://docs.privacytracker.privacykey.org/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).

Two parser eras are auto-detected per capture:

- **Modern (Nov 2025+)** — Apple's redesigned web App Store embeds privacy data in `<script id="serialized-server-data">`.
- **Historical (Jan 2021 – Nov 2025)** — Apple's older Ember/FastBoot site embeds the same data in `<script type="fastboot/shoebox" id="shoebox-media-api-cache-apps">`. The two shapes share identifier enums (`DATA_USED_TO_TRACK_YOU`, etc.), so downstream snapshot diffing and severity styling work without translation.

Captures from before Q1 2021 pre-date the web rollout of nutrition labels and are correctly skipped by the parser.

| Setting (in `app_settings`) | Purpose |
|---|---|
| `wayback_show_imported` | `'true'` / `'false'` — controls whether the per-app timeline renders imported rows by default. |
| `wayback_import_running` | Cross-request mutex during a bulk run. |
| `wayback_bulk_state` | Resume state blob (queue, totals, initiator). Survives a process kill. |

Bulk runs are crash-safe by design: every app boundary rewrites the state blob, and `instrumentation.ts` schedules a resume check 8 seconds after boot. If the queue has unfinished work, the run resumes automatically with `initiator: 'resume'` and the SettingsView surfaces a purple "↻ Resumed after restart" pill.

![Wayback timeline row](https://docs.privacytracker.privacykey.org/images/wayback-timeline.png)

*Wayback import row inside an app's change history*

Endpoints:

- `POST /api/apps/[id]/import-history` — single-app backfill.
- `POST /api/wayback/import-all?stream=1` — bulk backfill, NDJSON event stream.
- `DELETE` on either purges the corresponding wayback rows.

When an empty quarter has no Wayback capture anywhere in the ±42-day tolerance window, the importer fires `submitToWaybackSaveNow(app.url)` so a future run can pick it up. Submissions are deduplicated within a single run.

## Crash-safe resume (all three jobs)

| Job | Mutex key | State blob |
|---|---|---|
| App Store sync | `sync_running` | `sync_bulk_state` |
| Wayback import | `wayback_import_running` | `wayback_bulk_state` |
| Policy sync | `policy_sync_running` | `policy_bulk_state` |

`GET /api/tasks/active` returns a unified `{ wayback, sync, policy }` snapshot. The TaskCenter widget polls it every 4 seconds and surfaces resumed runs as "Resumed after restart" cards.

## Translations

The UI ships through next-intl. Two locales currently ship — **English (`en`)
and Simplified Chinese (`zh`)** — at full key parity. The supported set is
declared as `SUPPORTED_LOCALES` in `i18n.ts`.

The active language is resolved per request from the `NEXT_LOCALE` cookie,
falling back to `en` when the cookie is absent or holds an unsupported value.
Routes are flat, with no per-locale URL prefix, so additional locales appear
automatically as they're translated — drop a `locales/<code>.json` and append
the code to `SUPPORTED_LOCALES`.

`locales/en.json` is the source of truth; every other `locales/<lang>.json` is
round-tripped through Crowdin (free OSS plan). Adding or changing locales is a
developer task — see [Translations](https://docs.privacytracker.privacykey.org/develop/translations) under the Develop
tab for the full workflow.

```bash
pnpm lint:i18n   # check key parity against en.json
```

## Health and readiness

| Endpoint | Use it for |
|---|---|
| `GET /api/health` | Cheap liveness probe (uptime checks). |
| `GET /api/ready` | Container healthcheck — DB reachable + data dir writable. |
| `GET /api/deployment/diagnostics` | Safe deployment diagnostics for the Settings page. |
| `GET /api/deployment/support-bundle` | Copy/paste-safe deployment support bundle. |
