siftlode/docs/deployment-plan.md
npeter83 898a6ae338 chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).

Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00

10 KiB

Siftlode — Public Deployment Plan (v1)

Status: approved (planning) — 2026-06-13. Goal: share the current Siftlode 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 Siftlode; 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, siftlode.b1fr0st.eu)
                            │  reverse_proxy 127.0.0.1:8080
                            ▼
                   siftlode-api (FastAPI + built SPA, Docker)
                            │  internal docker network only
                            ▼
                   siftlode-db (Postgres 16 — never published)
  • DNS: porkbun add b1fr0st.eu A siftlode 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 siftlode.b1fr0st.eu.
  • Caddy: new vhost block siftlode.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 } inheriting the existing security-header set (HSTS, X-Frame-Options, etc.).
  • OAuth redirect URI: https://siftlode.b1fr0st.eu/auth/callbackOAUTH_REDIRECT_URL. Registered in the Google OAuth client.
  • Stack: dedicated prod compose at /srv/siftlode/; 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)

  • siftlode-api: mem_limit: 768m, cpus: 1.0, pids_limit: 256, no-new-privileges, cap_drop: ALL, read-only rootfs + tmpfs /tmp, non-root user.
  • siftlode-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 siftlode DB (rides the nightly tar.zst; pulled to the home array by pull-asgard-backups).
  • Monitoring: Prometheus blackbox probe for siftlode.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 (Siftlode 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/siftlode:<tag>).
  3. Deploy step in the same workflow: cd /srv/siftlode && 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/siftlode/ 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.