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
Network exposure
privacytracker’s default posture is loopback-only, and it enforces that by checking the request’sHost 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.
Build and runtime
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).
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
disabled
No AI calls are made. Privacy-policy text is still followed and hashed, but no
summary_json is generated.openai
openai
OpenAI Chat Completions. Set the model in Settings → AI → Model. The provider check (
providerRequiresApiKey) blocks saving without a key.anthropic
anthropic
Anthropic Messages API. Same UI, different default model.
custom
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
Local Ollama setup
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:
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: theflag.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:
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, fourapp_settings keys drive it:
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 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:
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.
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 asAPP_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.
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 import row inside an app's change history
POST /api/apps/[id]/import-history— single-app backfill.POST /api/wayback/import-all?stream=1— bulk backfill, NDJSON event stream.DELETEon either purges the corresponding wayback rows.
submitToWaybackSaveNow(app.url) so a future run can pick it up. Submissions are deduplicated within a single run.
Crash-safe resume (all three jobs)
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 under the Develop
tab for the full workflow.