# Privacy niche-finder is self-hosted software. You run it on your own machine, against your own Postgres, with your own YouTube API key. There is no niche-finder service, no account, and no server of ours for your data to reach — so for the purposes of any privacy regulation, **you are the data controller and we are not a processor**. Nothing here is a promise about a service we operate; it is a description of what the code does, which you can verify yourself. ## No telemetry The project collects no usage data, no crash reports and no analytics. There is no opt-out because there is nothing to opt out of: `grep -ri "telemetry\|sentry\|posthog\|analytics" backend/ frontend/ extension/` returns nothing. ## What is stored, and where Everything lives in the Postgres database you point the app at (by default the `niche-finder-postgres-data` Docker volume on your machine): - **Public YouTube metadata** — videos, channels, titles, descriptions, tags, thumbnails, view/like/comment counts, and a time series of stats snapshots built by repeatedly re-reading those public counters. - **Your own working data** — the watchlist (`tracked_channels`), the swipe file (`saved_items`), metadata drafts (`drafts`), and alert events (`events`). - **Embeddings** — vectors computed locally from titles and descriptions for semantic search. - **Thumbnail versions** (`thumbnail_archive`) — the 320x180 thumbnail image of tracked channels' recent videos, one copy per distinct version, so a replaced thumbnail can still be shown as "before". - **Thumbnail vectors** (`videos.thumb_embedding`, plan 13, opt-in) — a 512-number CLIP vector per thumbnail for "similar thumbnails" and thumbnail styles. Only the vector is kept, never the image (plan-05 archived images are reused when present). - **Accounts** (plan 15, only with `NF_MULTI_USER=1`) — each user's email, a scrypt hash of the password (never the password), and the SHA-256 of each sign-in session token with its expiry (`users`, `sessions`). Each session also keeps the client address and browser line (User-Agent, cut to 200 characters) it was used from and when it was last active, so its owner can see where they are signed in on the "Profile" page; they go when the session ends. With mail set up, a sign-up, reset or email-change link is kept as a SHA-256 with its purpose, the new address for an email change and its expiry (`email_tokens`); it is deleted when used, replaced, or after it expires, when the next link is issued. Personal rows carry the owner's `user_id`: the watchlist, swipe file, drafts, transcript queue and which alerts each user has read (`event_reads`) are visible only to their owner. Personal API tokens (`api_tokens`) are stored as SHA-256 only; the extension keeps its token in `chrome.storage.local` and sends it only to the backend address you set. Each user's notification settings (`user_settings`): the Telegram chat id in clear, the bot token and webhook address encrypted with `OWN_TOKENS_KEY`. - **Two-factor sign-in** (only if you turn it on, multi-user plan P2-04) -- the authenticator secret encrypted with `OWN_TOKENS_KEY` and the 30-second step of the last accepted code (`user_totp`), scrypt hashes of the backup codes with when each was used (`user_backup_codes`), and for a sign-in waiting for its code the SHA-256 of a one-time challenge for 5 minutes (`mfa_challenges`). A session row also notes when it passed the second factor (`sessions.mfa_at`). All of it goes when 2FA is turned off and with the account. - **Time zone** (P2-03) -- the IANA name a person picked or the dashboard took from their browser on first sign-in (`users.timezone`, e.g. `Europe/Moscow`), used to send their digest in their morning and to write times in their reminders. Nothing finer than the zone; it goes with the account and can be cleared on the "Profile" page. - **Tariffs** (only with `NF_MULTI_USER=1`) -- the tariffs themselves (`plans`: name, price, limits and features) are shared settings, not data about anyone. Per user, `user_plans` keeps the user's tariff, its start and end date and who assigned it (the user, an admin or the system), and `plan_changes` is a log of every tariff change with the same facts. Both are personal rows with the owner's `user_id`. They go with the account (see "Deleting an account" below). - **Deleting an account** (only with `NF_MULTI_USER=1`) -- a person deletes their own account on the "Plan" page with their password; an admin does it on the "Admin" page (typing the email back) or with `cli.py delete-user EMAIL --yes`. It removes every row with that user's `user_id` (watchlist, saved items, drafts, alerts and their delivery log, topics, settings and notification secrets, tariff rows, usage, sessions and API tokens, own channels with their metrics), their daily quota counters, and the account itself; the OAuth tokens of their own channels are revoked at Google first. What is shared stays: the public YouTube data everyone collects, and a transcript or tag they added, which passes to the operator. The action log (below) keeps who deleted which email and when. Blocking an account (Admin page, `cli.py disable-user`) deletes nothing but ends its sessions and tokens at once. - **Action log** (`audit_log`, multi-user plan P1-07) -- a security record only admins read (the "Admin" page, `GET /api/admin/audit`): sign-ins and failed sign-ins, sign-ups, password changes and resets, email changes (the old and the new address), API tokens created and revoked (id, name, scopes, expiry), tariff changes, and admins' actions on accounts and tariffs. Each entry has the time, the account that acted and the account acted on (as numbers; the email only where the account may disappear: creating and deleting it, an email change), the client address (the same one the sign-in throttle uses) and short details. It **never** holds a password, a token, a reset link or a secret: such fields are dropped before writing. A failed sign-in names the account only if it exists -- whatever was typed for an unknown email is not written down, since it is sometimes a password. The log has no `user_id` column and **outlives a deleted account**: who signed in from where and who deleted what is exactly what a security log is for. Entries are removed by age instead: `NF_AUDIT_RETENTION_DAYS` (180 by default; 0 keeps them forever), checked by the worker once a day. - **Support may look at your account** (multi-user plan P2-05). To answer a question, an admin of the server can open the dashboard as you see it -- your watchlist, saved items, drafts, alerts, own channels' numbers and tariff -- read-only and for a limited time (`NF_IMPERSONATION_MINUTES`, 30 minutes by default). They cannot change, save, delete or start anything on your behalf, do not spend your YouTube or LLM quota, and do not see your sessions (addresses, browsers), API tokens or passwords. The action log records who looked, when, for how long and the reason they gave (`impersonation_start`, `impersonation_end`, `impersonation_expired`); these entries follow the log's retention above. The view is not shown in your profile yet: ask the server's operator for the log entries about you. - **Your own channels** (plan 14, only if you connect one) — the channel id and title, the granted scopes, the OAuth refresh token **encrypted** with `OWN_TOKENS_KEY` from your `.env` (Fernet; the key is never in the database), and per-video YouTube Analytics numbers (views, watch time, retention, subscribers gained, revenue, CPM) for the last 28 days and lifetime (`own_channels`, `own_video_metrics`), plus a year of daily views, watch minutes and subscribers gained by format -- Shorts, long videos, live (`own_channel_daily`, plan 24). This is private data about you. It never leaves your database except as the read requests to Google below; the token is never logged or returned by the API. **Отключить** on the "Мои каналы" screen revokes the token at Google and deletes all of it; you can also remove access at https://myaccount.google.com/permissions. The YouTube metadata is public information about other people's channels, retrieved through YouTube's official API. It is not private data about you, but it is data about third parties, and how you use it is governed by the [YouTube Terms of Service](https://www.youtube.com/t/terms) and the [YouTube API Services Terms](https://developers.google.com/youtube/terms/api-services-terms-of-service). **Comments are not stored as themselves.** The `video_comments` tool reads comment threads live from the API and hands them straight back to the caller; nothing is written to the database (the one narrow exception, questions without authors, is `content_gaps` below). This is deliberate — comments are user-generated content attached to identifiable authors, and keeping a local copy of them is a liability the tool does not need. **Exception: `comment_insights` (stage 04), and only when you call it.** This tool sends comment *text* (not author names) to OpenRouter to extract pains/requests/video ideas, and caches the LLM's *output* (not the raw comments) in `video_insights`. It never runs on its own — the worker has no comment-fetching step, and neither the dashboard nor the MCP server call it automatically. Like every other `llm_gateway` call, it is a no-op unless you have already set `LLM_PROVIDER=openrouter`, and it additionally requires an explicit click (dashboard) or tool call (MCP) naming a specific video — there is no bulk or background mode. **Second exception: `content_gaps` (plan 03), and only when you ask it to fetch.** With `fetch=true` (the "Прочитать комментарии" button on a niche screen) it reads the top comments of that niche's most-viewed videos, 1 quota unit per video. Without an LLM it keeps, per video, only the comments that ask a question or request a video -- their **text and like count, never the author** -- in `video_insights` (task `comment_questions`), for `LLM_INSIGHTS_TTL_DAYS`, so a repeat call does not spend quota again. With an LLM it goes through `comment_insights` above and stores only that output. The worker never calls it; without `fetch` it only reads that cache. **Transcripts (stage 19) are never fetched — you paste them, and they stay local.** There is no subtitle-download code anywhere in this project: you copy the text off YouTube's own transcript panel and paste it into the dashboard's Транскрипты screen (or via the MCP tool). The pasted text and its chunk embeddings are stored only in your own Postgres (`transcripts`/`transcript_chunks`) -- nothing about a transcript is sent to YouTube, OpenRouter, or anywhere else. Chunk embeddings are computed by the same local `fastembed` model everything else in this project uses (see "Optional LLM enrichment" above for the one-time model download, the only network call in this whole feature). *One exception, opt-in and per video:* `hook_report(llm=true)` (the "LLM-разбор" checkbox next to the "Крючок" button) sends the text of the first ~30 seconds of ONE transcript to your configured LLM provider. It never happens automatically, and it is a no-op with `LLM_PROVIDER=none`. The cache (`video_insights`, task `hook`) stores only the model's answer and a hash of that text, not the text itself. `score_hook_text` (the "Проверить вступление" card) scores a draft intro in memory: it does not save it, log it, or send it anywhere. ## How long it is kept Nothing is deleted automatically. Rows you collected stay until you delete them (see "Deleting everything" below). Once a day the worker re-reads from the YouTube API every stored video and channel not refreshed for 25 days (`REFRESH_STALE_DAYS`), oldest first, up to `REFRESH_STALE_MAX_VIDEOS` videos and `REFRESH_STALE_MAX_CHANNELS` channels: titles, descriptions, tags and counters are replaced with what YouTube shows now, and a new snapshot is added. A video or channel the API no longer returns is marked "gone" and kept. The "Данные" screen shows how many rows are older than that. The history (stats snapshots, title and thumbnail changes, archived thumbnails) is kept without limit; YouTube's API policies allow that only with their approval, see SECURITY.md. ## Running it for others An installation run as a service serves public `/privacy` and `/terms` pages for its customers (plan 25): what the service collects, the Google API Services Limited Use disclosure, links to the YouTube Terms of Service and the Google Privacy Policy, how to revoke access and ask for deletion, filled with the operator's name and contact from `.env`. This document stays the technical description of what the code stores. ## Where network traffic goes By default — `LLM_PROVIDER=none`, the setting nothing changes out of the box — the application contacts exactly three external hosts: | Host | Why | Sends your API key? | |---|---|---| | `www.googleapis.com` | YouTube Data API v3 — every quota-spending call | Yes, in the `X-Goog-Api-Key` header | | `www.youtube.com` | Channel RSS feeds (`/feeds/videos.xml`), used by the free upload watcher | No | | `i.ytimg.com` | Thumbnail images of tracked channels' videos from the last `WORKER_FULL_PERIOD`, every `WORKER_THUMBS_INTERVAL_MIN`, to notice thumbnail swaps (the API cannot). Plain image GETs — Google sees which videos you check. `WORKER_THUMBS=0` turns it off | No | | `i.ytimg.com` | Plan 13, **off by default**: with `WORKER_THUMB_EMBED=1` (or the "Посчитать векторы превью" button / `embed_thumbnails`) the thumbnails of any collected videos, to compute their CLIP vectors locally; the image is dropped after that | No | | `accounts.google.com`, `oauth2.googleapis.com` | Plan 14, only when you connect your own channel: the sign-in page, then the code-for-token exchange, token refresh before each sync, and the revoke on disconnect | Your OAuth client id/secret and refresh token, to Google only | | `youtubeanalytics.googleapis.com`, `www.googleapis.com` | Plan 14: your channel's Analytics reports (daily, and on "Обновить цифры") and which channel the token belongs to | The OAuth access token, to Google only | | `huggingface.co` | Plan 13: the CLIP image model (~0.34 GB) the first time a thumbnail is embedded, and the CLIP text model (~0.25 GB) the first time thumbnails are searched by text; cached in the models volume after that | No | Two more appear the first time you set the project up, and are not the running application: - **Hugging Face** — `fastembed` downloads the embedding model (`paraphrase-multilingual-MiniLM-L12-v2`) on first use and caches it under `HF_HOME=/models`. Build with `--build-arg PREFETCH_MODEL=1` to fetch it at image build time instead. After that, embeddings are computed locally and no text ever leaves your machine for them. - **`auth.docker.io`** — `docker compose pull`, i.e. Docker itself. If you see `www.w3.org` reported by an automated scanner, it is a false positive: the string appears once, in [`backend/infrastructure/youtube/rss.py`](backend/infrastructure/youtube/rss.py), as the Atom XML namespace identifier. Namespace URIs are names, not addresses; nothing fetches it. ## Optional LLM enrichment Off by default (`LLM_PROVIDER=none`). Every call to [`backend/application/llm_gateway.py`](backend/application/llm_gateway.py) returns `None` without touching the network unless you set `LLM_PROVIDER=openrouter` and an `OPENROUTER_API_KEY`. | Host | Why | Sends your API key? | |---|---|---| | `openrouter.ai` | Chat completions for whatever feature calls `llm_gateway.run()` | Yes (`OPENROUTER_API_KEY`, never your YouTube key) | What actually goes out is only what the calling code builds as `system`/ `user` text and a JSON schema — a title, a description, a short prompt about one video or channel, or (only for `comment_insights`, see above) the text of that video's comments — never your YouTube API key. Every request is cached in Postgres (`llm_cache`, keyed by task + model + normalized input) so the identical request is never sent twice, and `llm_usage` tracks daily spend against `LLM_DAILY_BUDGET_USD` (default $1.00/day) so a runaway loop can't run up an unbounded bill. Leave `OPENROUTER_MODEL` unset and the request goes to whichever model in `infrastructure/llm/openrouter.py:FREE_MODEL_FALLBACKS` answers first — all of OpenRouter's free (`:free`) tier, $0 per token, so `llm_usage.cost_usd` stays 0 and the daily budget effectively never triggers. Free models carry their own OpenRouter-side rate limit (50 requests/day without purchased credits, 1000/day with $10+), independent of `LLM_DAILY_BUDGET_USD`. Set `OPENROUTER_MODEL` to pin one specific model — free or paid — instead. **`LLM_PROVIDER=ollama` (stage 11) sends nothing anywhere.** `infrastructure/ llm/ollama.py` talks only to `OLLAMA_URL` — your own [Ollama](https://ollama.com) install, whether that's the host machine (`127.0.0.1:11434`) or the optional `ollama` service in `docker-compose.yml` (`--profile llm-local`, still on your machine, just in its own container). No OpenRouter call, no external host, no API key — every `llm_gateway.run()` request that would otherwise go to `openrouter.ai` goes to that local server instead, and `llm_usage.cost_usd` is always 0. The one exception is the model file itself: the first `ollama pull ` downloads it from Ollama's own registry, same one-time category as the `fastembed` download mentioned above. ## Watched topics A topic you name on the "Алерты" screen, through `watch_topic` or the extension's "Следить за темой" button is stored as its text and a local embedding of it (`user_topics`, personal: `user_id`). Matching runs inside your database; the text goes nowhere. Removing a topic deletes its row; the alerts it raised stay in your feed. ## Alert delivery (Telegram / webhook) Off by default -- the worker only writes detected events (outlier, acceleration, title change, silence break) to Postgres; nothing is sent anywhere until you set `NOTIFY_TELEGRAM_BOT_TOKEN` + `NOTIFY_TELEGRAM_CHAT_ID`, or `NOTIFY_WEBHOOK_URL`, in `.env`. | Host | Why | Sends your bot token? | |---|---|---| | `api.telegram.org` | `sendMessage` for each new alert (or a batched summary), and/or one daily digest when `NOTIFY_MODE` is `digest`/`both` | Yes, as part of the URL path -- masked in any logged error | | your `NOTIFY_WEBHOOK_URL` | Same, as a POST body `{"text": ...}` | No | What goes out is only the alert's own numbers and a video/channel title -- the same data `list_events`/the dashboard's alerts view already show you, never comment text or anything from `comment_insights`. The bot token itself is never logged (masked in every error path in `infrastructure/notify/telegram.py`) and never written to the database; `alert_deliveries` stores only which alert was sent and when, not the message text. ## Your API key `YOUTUBE_API_KEY` is read from `.env` at startup and used only to call `www.googleapis.com`, sent in the `X-Goog-Api-Key` header and never in a URL, so it does not appear in error messages or logs. It is not written to the database and not sent anywhere else. `.env` is listed in `.gitignore`, so it does not end up in git — but it is a plaintext file on your disk, so treat it like any other credential and do not commit it or paste it into an issue. ## Network exposure Every published port binds to `127.0.0.1` only — Postgres (`5433`), the dashboard and HTTP API (`8080`), and the optional MCP HTTP transport (`8765`). Nothing is reachable from your local network or the internet unless you change that yourself. The HTTP API also applies a request limit (`RATE_LIMIT_PER_MINUTE`, default 600); on a loopback-only service that is a fuse against a looping client rather than a defence against outside traffic. ## The Chrome extension The extension talks only to the one service address set in its popup. It does not send anything to any third party. - **Local by default.** Its permanent `host_permissions` are `http://127.0.0.1/*` and `http://localhost/*` only, the default address is `http://127.0.0.1:8080`, and with it nothing leaves your machine. - **Your own domain, if you choose.** When you enter another address (a server you or your team runs, see [docs/hosting.md](docs/hosting.md#the-chrome-extension)), the popup asks Chrome for access to that one host (`optional_host_permissions`). You see Chrome's prompt and can refuse; switching to another address gives the old host's access back. You can also revoke it on `chrome://extensions`. - **What goes to that service** is what the panels need: the IDs of the videos and channels you open or see in lists, the text you type into the popup's metadata review or "watch this topic", and your personal API token as `Authorization: Bearer`. The token goes to a remote address only over `https`; over plain `http` the extension does not send it. - **Where the token lives.** In `chrome.storage.local` (not synced to your other browsers). The popup never shows it back. - **The YouTube page itself.** The extension reads the page you are on (video and channel IDs, which video cards are shown) to draw its panels; it does not read your YouTube account, cookies or history. ## Deleting everything Stop the stack and drop the volume that holds the database: ```sh docker compose down docker volume rm niche-finder-postgres-data ``` To drop the cached embedding model as well: ```sh docker volume rm niche-finder-models ``` Both are recreated empty on the next `docker compose up`. If you pointed the app at your own Postgres via `NICHE_DATABASE_URL`, drop that database or schema instead. ## Changes This document describes the code as it stands. If the set of hosts contacted or data stored changes, the change belongs in the same commit, and in [CHANGELOG.md](CHANGELOG.md).