diff --git a/docs/deployment-plan.md b/docs/deployment-plan.md new file mode 100644 index 0000000..e1e794a --- /dev/null +++ b/docs/deployment-plan.md @@ -0,0 +1,209 @@ +# Subfeed — Public Deployment Plan (v1) + +Status: **approved (planning)** — 2026-06-13. Goal: share the current Subfeed +build with one trusted tester (both users in Hungary), on safe public +infrastructure, with a clean and trustworthy Google sign-in experience. + +This is a planning document. No implementation is started until each phase is +explicitly greenlit. + +--- + +## 1. Confirmed decisions + +| Topic | Decision | +|---|---| +| Google verification level | **Production (unverified)** — not full verification for now | +| Sign-in / scopes | **Incremental authorization via an onboarding wizard** (see §2) | +| Hosting | **All on the asgard VPS** (UI + backend + DB) | +| Cloudflare in front | **No** for now (invite-only, unadvertised URL → tiny attack surface) | +| Repo visibility to tester | **Private repo + read access** for the friend | +| Security review timing | **Hybrid**: baseline audit early (folded into Phase A), focused review before publish | + +### Why not full verification (yet) +The "Google hasn't verified this app" warning is removed **only** by full +verification (homepage on a verified domain, privacy policy + ToS, app logo, +demo video, scope justification, ~10-day review). For a 2-person test that is +overkill. **Production-unverified** keeps a single one-time click-through +warning per user but removes the painful Testing-mode behaviour (7-day refresh +token expiry → weekly re-login). Full verification stays as an optional later +phase (§6). + +Sources: Google "Sensitive scope verification", "Verification requirements" +(support 13464321), "Manage App Audience" (15549945, the 7-day rule), +"Unverified apps" (7454865). + +--- + +## 2. Onboarding wizard — incremental scopes (core design) + +**Key insight:** `openid email profile` are **non-sensitive** scopes. An app +that requests only these shows **no unverified warning at all**, and the grant +**does not expire after 7 days even in Testing mode**. The sensitive +`youtube.readonly` (and the optional `youtube` write) are what trigger the +warning. + +Therefore: + +1. **Base login** requests only `openid email profile` → user signs in with a + clean, familiar Google screen, no warning, lands in the app. +2. **Onboarding wizard** (first login, also reachable later from settings) + walks the user through *optional, explained, one-at-a-time* grants: + - **Step — read access (`youtube.readonly`)**: explains it is needed to read + your subscriptions and build the feed; pre-warns about the Google + "unverified app" screen and how to proceed (Advanced → Continue). Button → + incremental consent. Skippable. + - **Step — write access (`youtube`)**: optional; needed to unsubscribe from + within Subfeed; same pre-warning. Button → incremental consent. Skippable. +3. App **degrades gracefully**: no read scope → no feed (UI explains why); no + write scope → no unsubscribe button. + +**Why this design:** the tester consciously grants each permission, with a +rationale, and can revoke on Google's side anytime — the cleanest answer to "I +don't fully trust an AI-written app." It is also Google's recommended +"incremental authorization" / "narrowest scope" best practice, which makes a +future verification submission cleaner. + +### Code implications (Phase A) +- `backend/app/auth.py`: split current `READ_SCOPES` into `BASE_SCOPES` + (`openid email profile`, used at `/auth/login`) and the YouTube scopes + (granted via incremental upgrade). Reuse/extend the existing `/auth/upgrade`. +- Add `has_read_scope()` alongside `has_write_scope()`. +- Scope-gate the feed/import endpoints; expose `/api/me/scopes` so the frontend + knows when to show the wizard / which cards to render. +- Frontend: wizard UI + explanatory copy + pre-warning about the Google screen. + +--- + +## 3. Infrastructure — all on asgard + +``` +User browser ──HTTPS──> Caddy (asgard :443, subfeed.b1fr0st.eu) + │ reverse_proxy 127.0.0.1:8080 + ▼ + subfeed-api (FastAPI + built SPA, Docker) + │ internal docker network only + ▼ + subfeed-db (Postgres 16 — never published) +``` + +- **DNS**: `porkbun add b1fr0st.eu A subfeed 88.218.78.254` (+ AAAA). Agent-owned + via `/usr/local/bin/porkbun` on the PVE host. +- **TLS**: Caddy DNS-01 (porkbun plugin), same as forge/pages — auto cert for + `subfeed.b1fr0st.eu`. +- **Caddy**: new vhost block `subfeed.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` + inheriting the existing security-header set (HSTS, X-Frame-Options, etc.). +- **OAuth redirect URI**: `https://subfeed.b1fr0st.eu/auth/callback` → + `OAUTH_REDIRECT_URL`. Registered in the Google OAuth client. +- **Stack**: dedicated prod compose at `/srv/subfeed/`; app bound to + **127.0.0.1:8080 only** (behind Caddy, never a public port); Postgres on the + internal docker network only (no host port). + +### Resource limits (critical on the 3.3 GB box) +- `subfeed-api`: `mem_limit: 768m`, `cpus: 1.0`, `pids_limit: 256`, + `no-new-privileges`, `cap_drop: ALL`, read-only rootfs + tmpfs `/tmp`, + non-root user. +- `subfeed-db`: `mem_limit: 512m`, `cpus: 0.75`, tuned + `shared_buffers`/`work_mem`. + +--- + +## 4. Security — layered defence + +Baseline already in place on asgard (from infra notes): UFW (80/443/2222/222 +only), fail2ban (sshd + caddy + forgejo, with home-IP DDNS auto-whitelist), +sysctl hardening, Docker daemon hardening (no-new-privileges, userland-proxy +off, icc off, log caps), auditd, key-only SSH, swap. + +| Threat | Mitigation | +|---|---| +| **Bot registration spam** | Strongest control already built: invite/allowlist-only. Set `ALLOWED_EMAILS` to exactly the two emails → no open registration; double gate (Google login + email allowlist). Do **not** open registration during the test. | +| **App breach / OAuth token theft** | Tokens already encrypted at rest (Fernet). Strong prod `SECRET_KEY` / `TOKEN_ENCRYPTION_KEY`. Verify session cookie flags (Secure/HttpOnly/SameSite); audit OAuth `state`/CSRF; check SSRF in the YouTube client. | +| **Server kill (resource exhaustion / OOM)** | Container hard-limits above; Caddy per-IP rate limit on `/auth/*` and API; Postgres connection cap. | +| **DDoS** | Honest limit: a small VPS can't absorb a large volumetric DDoS. Realistic layers: Caddy rate-limit + fail2ban on Caddy access log (present). Cloudflare free declined for now (re-evaluate if attacked). | +| **AI-written code flaws** | Dedicated security review of the codebase (§5). | + +- **Backups**: extend the existing `asgard-snapshot` timer with a `pg_dump` of + the subfeed DB (rides the nightly tar.zst; pulled to the home array by + `pull-asgard-backups`). +- **Monitoring**: Prometheus blackbox probe for `subfeed.b1fr0st.eu` + + container-up alert, mirroring the Forgejo probes. + +--- + +## 5. Trust-building for the tester + +- Minimal default scope (the wizard) → zero YouTube access and zero warning at + sign-in. +- Private repo **read access** for the friend → he can inspect exactly which + scopes are requested and what the app does with tokens. +- Permissions granted voluntarily, step by step, with rationale; revocable on + Google's side anytime (the wizard says so). +- Optional: self-host — the app is fully Dockerized; the friend could run his + own instance (ultimate trust option; offered, not required). + +--- + +## 6. Deploy pipeline (Forgejo Actions, near one-click) + +The runner already runs **on the same asgard host** as the app: + +1. Push to a **private Forgejo repo** at `forge.b1fr0st.eu` (Subfeed is + currently local-git only). +2. **Actions workflow** on tag push: multi-stage Docker build → push to the + built-in registry (`forge.b1fr0st.eu/peter/subfeed:`). +3. **Deploy step** in the same workflow: `cd /srv/subfeed && docker compose pull + && docker compose up -d` (local, since the runner is on asgard); migrations + run via the entrypoint's `alembic upgrade head`. + +Result: `git tag vX && git push --tags` → live in minutes. + +--- + +## 7. Work packages (phased) + +**Phase 0 — baseline security audit (do early; fold fixes into Phase A)** +- OAuth flow, token storage, session cookies, secret handling, dependency audit + (`pip-audit` + `npm audit`), Caddy headers. Since Phase A refactors auth, + findings here are fixed during that refactor (avoids re-auditing churned code). + +**Phase A — required for sharing** +1. Onboarding wizard + scope split (§2): `auth.py` base/incremental scopes, + `has_read_scope()`, scope-gated endpoints, `/api/me/scopes`, wizard UI + copy. +2. Public **landing/about**, **Privacy Policy**, **Terms of Service** pages + (reachable without login). Privacy Policy references the YouTube ToS + (https://www.youtube.com/t/terms) and Google Privacy Policy, includes the + Limited Use disclosure. +3. Prod config: strong secrets, `OAUTH_REDIRECT_URL`, `ALLOWED_EMAILS` = the two + emails, set `YOUTUBE_API_KEY` (decouples backfill from user tokens), + `FRONTEND_ORIGIN` empty in prod. +4. Google Cloud Console: OAuth consent screen (app name, logo, support email, + authorized domain `b1fr0st.eu`, privacy/ToS/homepage links), declare scopes, + **Publish app → Production**, verify domain in Search Console (TXT added via + the porkbun CLI by the agent). +5. Infra: DNS record, Caddy vhost, `/srv/subfeed/` compose stack with limits, + backup hook, monitoring probe. Forgejo repo + Actions pipeline. + +**Phase B — focused security review before publish** +- Review the new/changed code (wizard, new pages) right before the tester gets + a live token. + +**Phase C — optional, later** +- Full Google verification (demo video + scope justification) if the warning + must be removed entirely or 100+ users are expected. + +--- + +## 8. Open / external steps (need the user or browser) + +- Google Cloud Console actions (OAuth client, consent screen, publish, Search + Console) are behind login — the user clicks them per the agent's instructions, + or the agent drives via Claude-in-Chrome. +- DNS + asgard-side infra + deploy pipeline are agent-handled. + +## Notes / assumptions +- YouTube video playback streams from Google (embedded player), not through our + server → server bandwidth ≈ SPA bundle (cached) + JSON API; the asgard 5 TB/mo + cap is a non-issue for 2 users. +- YouTube Data API quota is 10,000 units/day per project (app already manages a + 9,000 budget) — ample for 2 users.