feat(demo): hidden /auth/demo login + require_human guard

Whitelisted emails enter the shared demo user via /auth/demo (lazily
created, no OAuth token/scope), rate-limited per IP and answering
uniformly so it can't be hammered as an enumeration oracle. Add a
require_human dependency that blocks the demo account from quota-spending
sync endpoints and the OAuth upgrade flow, and surface is_demo on /api/me.
This commit is contained in:
npeter83 2026-06-16 09:17:21 +02:00
parent 796eecb3ac
commit feb41f4514
3 changed files with 86 additions and 8 deletions

View file

@ -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,

View file

@ -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: