diff --git a/backend/alembic/versions/0014_demo_account.py b/backend/alembic/versions/0014_demo_account.py
new file mode 100644
index 0000000..3d9a8f3
--- /dev/null
+++ b/backend/alembic/versions/0014_demo_account.py
@@ -0,0 +1,59 @@
+"""demo account: users.is_demo + demo_whitelist
+
+Revision ID: 0014_demo_account
+Revises: 0013_playlist_fingerprint
+Create Date: 2026-06-16
+
+Adds the shared demo-account plumbing:
+ - users.is_demo — marks the single shared demo user (no OAuth token / YouTube scope).
+ - demo_whitelist — admin-curated emails that may enter the demo account from the login
+ page without Google sign-in. Multiple emails are just keys to the same shared door.
+The demo user row itself is created lazily on first demo login (works across all three DBs
+without a data migration), so this only adds the schema.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0014_demo_account"
+down_revision: Union[str, None] = "0013_playlist_fingerprint"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "users",
+ sa.Column(
+ "is_demo",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ )
+ op.create_index("ix_users_is_demo", "users", ["is_demo"])
+
+ op.create_table(
+ "demo_whitelist",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("email", sa.String(length=320), nullable=False),
+ sa.Column("note", sa.Text(), nullable=True),
+ sa.Column("added_by", sa.String(length=320), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+ op.create_index(
+ "ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_demo_whitelist_email", table_name="demo_whitelist")
+ op.drop_table("demo_whitelist")
+ op.drop_index("ix_users_is_demo", table_name="users")
+ op.drop_column("users", "is_demo")
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 685a211..8ebf762 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -11,11 +11,21 @@ from sqlalchemy.orm import Session
from app import email as email_mod
from app.config import settings
from app.db import get_db
-from app.models import Invite, OAuthToken, User
+from app.models import DemoWhitelist, Invite, OAuthToken, User
+from app.ratelimit import RateLimiter
from app.security import encrypt
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+# The single shared demo account's stable identity. The row is created lazily on first
+# demo login (see get_or_create_demo_user); these are just its sentinel keys.
+DEMO_GOOGLE_SUB = "__demo__"
+DEMO_EMAIL = "demo@siftlode.local"
+
+# Throttle the hidden demo-login endpoint per client IP: enough for a real paste-and-wait,
+# stingy enough that the field can't be hammered as an enumeration oracle or for DoS.
+_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
+
# How many recently-used accounts to keep switchable in one browser session.
MAX_SESSION_ACCOUNTS = 6
@@ -77,6 +87,45 @@ def is_allowed(db: Session, email: str) -> bool:
return email in settings.allowed_email_set or email in settings.admin_email_set
+def is_demo_allowed(db: Session, email: str) -> bool:
+ """Whether this email may enter the shared demo account (no Google sign-in)."""
+ if not email:
+ return False
+ return (
+ db.execute(
+ select(DemoWhitelist).where(DemoWhitelist.email == email.lower())
+ ).scalar_one_or_none()
+ is not None
+ )
+
+
+def get_or_create_demo_user(db: Session) -> User:
+ """The one shared demo user, created on first use. No OAuth token / YouTube scope, so
+ has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it."""
+ user = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
+ if user is None:
+ user = User(
+ google_sub=DEMO_GOOGLE_SUB,
+ email=DEMO_EMAIL,
+ display_name="Demo",
+ role="user",
+ is_demo=True,
+ preferences={"language": "en"},
+ )
+ db.add(user)
+ db.commit()
+ return user
+
+
+def _client_ip(request: Request) -> str:
+ """Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the
+ real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise."""
+ xff = request.headers.get("x-forwarded-for")
+ if xff:
+ return xff.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
+
+
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
"""Idempotently record an access request. Returns the Invite if a *new* pending row
was created (so the caller can notify admins), else None for already-known emails."""
@@ -224,6 +273,24 @@ async def request_access(
return {"status": "pending"}
+@router.post("/demo")
+def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -> dict:
+ """Hidden demo entry: a whitelisted email logs straight into the shared demo account,
+ no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
+ so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
+ exactly like a non-match (authenticated=false) so the field can't be hammered as an
+ enumeration oracle. On a match it sets the session and the client reloads into the app."""
+ if not _demo_limiter.allow(_client_ip(request)):
+ return {"authenticated": False}
+ email = (payload.get("email") or "").strip().lower()
+ if _EMAIL_RE.match(email) and is_demo_allowed(db, email):
+ demo = get_or_create_demo_user(db)
+ request.session["user_id"] = demo.id
+ log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
+ return {"authenticated": True}
+ return {"authenticated": False}
+
+
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id = request.session.get("user_id")
if not user_id:
@@ -241,6 +308,16 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return user
+def require_human(user: User = Depends(current_user)) -> User:
+ """Reject the shared demo account from actions that need a real Google/YouTube identity
+ or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
+ no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
+ where there's no scope check to lean on."""
+ if user.is_demo:
+ raise HTTPException(status_code=403, detail="Not available in the demo account.")
+ return user
+
+
@router.get("/upgrade")
async def upgrade(
request: Request, access: str = "read", user: User = Depends(current_user)
@@ -250,6 +327,10 @@ async def upgrade(
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
the least-privileged read scope."""
+ # This is the browser-facing redirect entry point, so the shared demo account is bounced
+ # straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
+ if user.is_demo:
+ return RedirectResponse(url="/")
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect(
request,
diff --git a/backend/app/models.py b/backend/app/models.py
index cf4d044..55f694c 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -28,6 +28,12 @@ class User(Base):
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
+ # The single shared "demo" account: signed into without Google OAuth (via a whitelisted
+ # email on the login page), has no OAuth token / YouTube scope, and its per-user state is
+ # shared by everyone who enters this way. Exactly one such row is expected.
+ is_demo: Mapped[bool] = mapped_column(
+ Boolean, default=False, server_default="false", index=True
+ )
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
preferences: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(
@@ -81,6 +87,22 @@ class Invite(Base):
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
+class DemoWhitelist(Base):
+ """Emails that may enter the shared demo account from the login page (no Google OAuth).
+ A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
+ shared demo user. Distinct from Invite (which gates real Google sign-in)."""
+
+ __tablename__ = "demo_whitelist"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
+ note: Mapped[str | None] = mapped_column(Text)
+ added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+
class Channel(Base):
"""A YouTube channel. Shared across all users (one channel's videos are the same
for everyone), so its expensive metadata is fetched and stored only once."""
diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py
new file mode 100644
index 0000000..7df0b5f
--- /dev/null
+++ b/backend/app/ratelimit.py
@@ -0,0 +1,43 @@
+"""A tiny in-process sliding-window rate limiter.
+
+Generic groundwork: keyed by an arbitrary string (e.g. a client IP), so it can throttle any
+endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which
+is fine for abuse throttling (not for anything that must survive a deploy). Not shared across
+processes — if we ever run multiple workers, swap the backing store for Redis behind the same
+``allow()`` interface.
+"""
+import threading
+import time
+
+
+class RateLimiter:
+ def __init__(self, max_events: int, window_seconds: float):
+ self.max_events = max_events
+ self.window = window_seconds
+ self._hits: dict[str, list[float]] = {}
+ self._lock = threading.Lock()
+
+ def allow(self, key: str) -> bool:
+ """Record an attempt for ``key`` and return whether it is within the limit.
+
+ True -> under the cap (the attempt is counted).
+ False -> the cap for the current window is already reached (attempt NOT counted, so a
+ blocked caller can't keep pushing the window forward)."""
+ now = time.monotonic()
+ cutoff = now - self.window
+ with self._lock:
+ hits = [t for t in self._hits.get(key, []) if t > cutoff]
+ if len(hits) >= self.max_events:
+ self._hits[key] = hits
+ return False
+ hits.append(now)
+ self._hits[key] = hits
+ # Opportunistic cleanup so abandoned keys don't accumulate unboundedly.
+ if len(self._hits) > 4096:
+ for k in list(self._hits):
+ fresh = [t for t in self._hits[k] if t > cutoff]
+ if fresh:
+ self._hits[k] = fresh
+ else:
+ del self._hits[k]
+ return True
diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py
index c544f4b..545a1ce 100644
--- a/backend/app/routes/admin.py
+++ b/backend/app/routes/admin.py
@@ -1,17 +1,29 @@
-"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add."""
+"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
+Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
+import re
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app import email as email_mod
-from app.auth import current_user
+from app.auth import current_user, get_or_create_demo_user
from app.db import get_db
-from app.models import Invite, User
+from app.models import (
+ DemoWhitelist,
+ Invite,
+ Playlist,
+ PlaylistItem,
+ User,
+ Video,
+ VideoState,
+)
router = APIRouter(prefix="/api/admin", tags=["admin"])
+_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+
def admin_user(user: User = Depends(current_user)) -> User:
if user.role != "admin":
@@ -98,3 +110,117 @@ def add_invite(
inv.decided_by = admin.email
db.commit()
return _serialize(inv)
+
+
+# --- Demo account: email whitelist + state reset -------------------------------------
+
+def _serialize_demo(row: DemoWhitelist) -> dict:
+ return {
+ "id": row.id,
+ "email": row.email,
+ "note": row.note,
+ "added_by": row.added_by,
+ "created_at": row.created_at.isoformat() if row.created_at else None,
+ }
+
+
+@router.get("/demo/whitelist")
+def list_demo_whitelist(
+ _: User = Depends(admin_user), db: Session = Depends(get_db)
+) -> list[dict]:
+ rows = (
+ db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc()))
+ .scalars()
+ .all()
+ )
+ return [_serialize_demo(r) for r in rows]
+
+
+@router.post("/demo/whitelist")
+def add_demo_whitelist(
+ payload: dict,
+ admin: User = Depends(admin_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ """Add an email that may enter the shared demo account from the login page (no Google
+ sign-in). Idempotent — re-adding an existing email just returns it."""
+ email = (payload.get("email") or "").strip().lower()
+ if not _EMAIL_RE.match(email):
+ raise HTTPException(status_code=400, detail="Enter a valid email address.")
+ row = db.execute(
+ select(DemoWhitelist).where(DemoWhitelist.email == email)
+ ).scalar_one_or_none()
+ if row is None:
+ row = DemoWhitelist(
+ email=email,
+ note=(payload.get("note") or "").strip() or None,
+ added_by=admin.email,
+ )
+ db.add(row)
+ db.commit()
+ return _serialize_demo(row)
+
+
+@router.delete("/demo/whitelist/{entry_id}")
+def remove_demo_whitelist(
+ entry_id: int,
+ _: User = Depends(admin_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ row = db.get(DemoWhitelist, entry_id)
+ if row is not None:
+ db.delete(row)
+ db.commit()
+ return {"deleted": entry_id}
+
+
+def _seed_demo_playlists(db: Session, demo: User) -> int:
+ """Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't
+ empty — gives visitors something to play with. Best-effort: skips silently if the
+ catalog is too small."""
+ recent = (
+ db.execute(
+ select(Video.id)
+ .where(Video.is_short.is_(False), Video.live_status == "none")
+ .order_by(Video.published_at.desc().nullslast())
+ .limit(60)
+ )
+ .scalars()
+ .all()
+ )
+ if not recent:
+ return 0
+ samples = [
+ ("Fresh picks", recent[0:10]),
+ ("Quick watch", recent[10:18]),
+ ("Saved for later", recent[18:26]),
+ ]
+ created = 0
+ for pos, (name, vids) in enumerate(samples):
+ if not vids:
+ continue
+ pl = Playlist(user_id=demo.id, name=name, position=pos)
+ db.add(pl)
+ db.flush()
+ for ipos, vid in enumerate(vids):
+ db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos))
+ created += 1
+ return created
+
+
+@router.post("/demo/reset")
+def reset_demo(
+ _: User = Depends(admin_user), db: Session = Depends(get_db)
+) -> dict:
+ """Wipe the shared demo account's per-user state (watch/save/hide states, playlists,
+ preferences) back to a clean baseline and re-seed a few sample playlists. There's no
+ automatic reset — this is the admin's manual 'clean up the sandbox' button."""
+ demo = get_or_create_demo_user(db)
+ db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
+ # Playlist items cascade on playlist delete (ondelete="CASCADE").
+ db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
+ demo.preferences = {"language": "en"}
+ db.commit()
+ seeded = _seed_demo_playlists(db, demo)
+ db.commit()
+ return {"reset": True, "playlists_seeded": seeded}
diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py
index 40fc3f3..df9e6a5 100644
--- a/backend/app/routes/me.py
+++ b/backend/app/routes/me.py
@@ -77,6 +77,7 @@ def get_me(
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"role": user.role,
+ "is_demo": user.is_demo,
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py
index 2af709e..8791aed 100644
--- a/backend/app/routes/sync.py
+++ b/backend/app/routes/sync.py
@@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
from sqlalchemy.orm import Session
from app import quota, state
-from app.auth import current_user, has_read_scope
+from app.auth import current_user, has_read_scope, require_human
from app.db import get_db
from app.models import Channel, Subscription, User, Video
from app.sync.runner import (
@@ -31,7 +31,7 @@ def _user_channels(db: Session, user: User) -> list[Channel]:
@router.post("/subscriptions")
def sync_subscriptions(
- user: User = Depends(current_user), db: Session = Depends(get_db)
+ user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
if not has_read_scope(user):
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
@@ -49,7 +49,7 @@ def sync_subscriptions(
@router.post("/rss")
def sync_rss(
- user: User = Depends(current_user), db: Session = Depends(get_db)
+ user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
new = run_rss_poll(db, _user_channels(db, user))
return {"new_videos": new}
@@ -57,7 +57,7 @@ def sync_rss(
@router.post("/backfill")
def sync_backfill(
- user: User = Depends(current_user),
+ user: User = Depends(require_human),
db: Session = Depends(get_db),
max_channels: int = 25,
) -> dict:
@@ -70,7 +70,7 @@ def sync_backfill(
@router.post("/enrich")
def sync_enrich(
- user: User = Depends(current_user), db: Session = Depends(get_db)
+ user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, "enrich"):
@@ -175,7 +175,7 @@ def my_status(
@router.post("/deep-all")
def deep_all(
- user: User = Depends(current_user),
+ user: User = Depends(require_human),
db: Session = Depends(get_db),
on: bool = True,
) -> dict:
diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py
index 64f4e04..2cdd79b 100644
--- a/backend/app/sync/runner.py
+++ b/backend/app/sync/runner.py
@@ -21,6 +21,7 @@ from app.sync.videos import (
enrich_pending,
poll_rss_channel,
reconcile_full_history,
+ refresh_live,
run_shorts_classification,
)
from app.youtube.client import YouTubeClient
@@ -65,6 +66,10 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
total += n
if n == 0:
break
+ # Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended
+ # stream gains its final duration + was_live status instead of staying "live" forever.
+ if quota.remaining_today(db) > floor:
+ total += refresh_live(db, yt)
return total
diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py
index e4672ed..c4f002d 100644
--- a/backend/app/sync/videos.py
+++ b/backend/app/sync/videos.py
@@ -5,7 +5,7 @@ import re
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
-from sqlalchemy import func, or_, select, update
+from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -260,6 +260,52 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
return enriched
+def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
+ """Re-fetch videos in a transient live state so they pick up their final shape.
+
+ `enrich_pending` is one-shot (it only touches enriched_at IS NULL), but a live broadcast
+ is not a final state: a stream ends and becomes a normal VOD — gaining a real duration
+ and flipping live_status to was_live — and an upcoming premiere eventually goes live then
+ ends. So we revisit anything still live/upcoming, plus a just-ended stream that hasn't got
+ its duration yet (YouTube can lag a bit after the broadcast), until it settles. Cheap:
+ videos.list is 1 unit per 50 ids, and the live/upcoming set is normally tiny."""
+ limit = limit or settings.enrich_batch_size
+ videos = (
+ db.execute(
+ select(Video)
+ .where(
+ or_(
+ Video.live_status.in_(("live", "upcoming")),
+ and_(
+ Video.live_status == "was_live",
+ Video.duration_seconds.is_(None),
+ ),
+ )
+ )
+ .limit(limit)
+ )
+ .scalars()
+ .all()
+ )
+ if not videos:
+ return 0
+ items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
+ now = _now()
+ updated = 0
+ for video in videos:
+ item = items.get(video.id)
+ if item is None:
+ # The broadcast vanished (deleted/private). Stop treating it as live so it
+ # doesn't get refreshed forever; it just becomes an ordinary (dead) row.
+ video.live_status = "none"
+ continue
+ apply_video_details(video, item)
+ video.enriched_at = now
+ updated += 1
+ db.commit()
+ return updated
+
+
def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
"""Finalize Shorts classification: cheaply mark non-candidates, then probe the
youtube.com/shorts URL for short-enough, non-live, enriched videos."""
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d74470c..688a049 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -34,6 +34,7 @@ import Toaster from "./components/Toaster";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner";
+import DemoBanner from "./components/DemoBanner";
import { CURRENT_VERSION } from "./lib/releaseNotes";
const DEFAULT_FILTERS: FeedFilters = {
@@ -189,6 +190,12 @@ export default function App() {
message: t("common.accessRequestsMessage", { count: pending }),
});
}
+ // The demo account is shared: there are no subscriptions, so default it to the whole
+ // library (the "my" feed would be empty). The communal-state warning is a permanent
+ // banner (see DemoBanner below), not a toast that re-pops on every reload.
+ if (meQuery.data?.is_demo) {
+ setFilters({ ...loadStoredFilters(), scope: "all" });
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
@@ -239,6 +246,7 @@ export default function App() {
setPage("channels");
}}
/>
+ {meQuery.data!.is_demo && }
openReleaseNotes(CURRENT_VERSION)} />
;
- // Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
- // with no toolbar (content-type/sort would be meaningless here).
+ // Empty "my" feed with no YouTube access. The shared demo account can never connect
+ // YouTube, so it gets a gentle nudge into the shared library instead of the connect
+ // wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my")
return (