diff --git a/app/routers/api.py b/app/routers/api.py
index 84e8ad6..66206f6 100644
--- a/app/routers/api.py
+++ b/app/routers/api.py
@@ -19,7 +19,7 @@ from collections import defaultdict
import httpx
from pydantic import BaseModel, Field
-from app.auth import require_token
+from app.auth import require_token, maybe_current_user, CurrentUser
from app.config import get_settings
from app.db import get_session, utcnow
from app.services.openrouter import (
@@ -228,11 +228,18 @@ async def news_list(
limit: int = Query(50, ge=1, le=500),
tags: str | None = Query(None, description="comma-separated include list"),
exclude_tags: str | None = Query(None, description="comma-separated exclude list"),
+ principal: CurrentUser | None = Depends(maybe_current_user),
as_: str | None = Query(default=None, alias="as"),
):
from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY
+ from app.services.access import FREE_NEWS_WINDOW_HOURS, is_paid_active
- cutoff = utcnow() - timedelta(hours=since_hours)
+ effective_hours = since_hours
+ capped = not is_paid_active(principal)
+ if capped:
+ effective_hours = min(since_hours, FREE_NEWS_WINDOW_HOURS)
+
+ cutoff = utcnow() - timedelta(hours=effective_hours)
stmt = select(Headline).where(Headline.published_at >= cutoff)
if category:
stmt = stmt.where(Headline.category == category)
@@ -275,7 +282,9 @@ async def news_list(
"tag_vocabulary": TAG_VOCABULARY,
"tag_labels": TAG_LABELS,
"active_include": sorted(include),
- "active_exclude": sorted(exclude)},
+ "active_exclude": sorted(exclude),
+ "capped": capped,
+ "window_hours": effective_hours},
)
return [HeadlineOut.model_validate(r, from_attributes=True) for r in filtered]
diff --git a/app/services/access.py b/app/services/access.py
index 9066f1d..731114a 100644
--- a/app/services/access.py
+++ b/app/services/access.py
@@ -22,6 +22,10 @@ from fastapi import Depends, HTTPException, status
from app.auth import CurrentUser, require_auth
from app.models import User
+# How many hours of news the free tier sees. Paid sees whatever the
+# endpoint's `since_hours` param requests (up to its own max).
+FREE_NEWS_WINDOW_HOURS = 6.0
+
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
diff --git a/app/templates/partials/news.html b/app/templates/partials/news.html
index 36b8a59..5f19f4d 100644
--- a/app/templates/partials/news.html
+++ b/app/templates/partials/news.html
@@ -33,3 +33,10 @@
{% endfor %}
{% endif %}
+{% if capped %}
+
+ Free tier — showing the last {{ window_hours|int }} hours of news.
+
Upgrade
+ for the full 24-hour feed plus daily and weekly email digests.
+
+{% endif %}
diff --git a/tests/test_news_window.py b/tests/test_news_window.py
new file mode 100644
index 0000000..ddaa0fa
--- /dev/null
+++ b/tests/test_news_window.py
@@ -0,0 +1,73 @@
+"""Free vs paid window clamp on /api/news.
+
+Integration-style: spins up a real router over an in-memory aiosqlite DB.
+Skips on hosts that lack aiosqlite + httpx — same pattern as
+test_portfolio_sync_api.py."""
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+
+def _build_app(tmp_path):
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+ from app import db as db_mod
+ from app.auth import sign_session
+ from app.db import Base
+ from app.models import Headline, User
+ from app.routers import api as api_router
+
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/news.db")
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ db_mod._engine = engine
+ db_mod._session_factory = factory
+
+ now = datetime.now(timezone.utc)
+
+ async def _seed():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ async with factory() as s:
+ s.add(User(id=1, email="free@x", tier="free"))
+ s.add(User(id=2, email="paid@x", tier="paid"))
+ for hours_old, title in ((1, "fresh"), (12, "mid"), (20, "old")):
+ s.add(Headline(
+ source="test", title=title, url=f"https://e/{title}",
+ category="general",
+ published_at=now - timedelta(hours=hours_old),
+ fetched_at=now,
+ tags=[],
+ ))
+ await s.commit()
+
+ asyncio.run(_seed())
+
+ app = FastAPI()
+ app.include_router(api_router.router, prefix="/api")
+ client = TestClient(app)
+ return client, sign_session(1), sign_session(2)
+
+
+def test_free_user_clamped_to_6h(tmp_path):
+ client, free_sess, _ = _build_app(tmp_path)
+ r = client.get("/api/news?since_hours=24",
+ cookies={"cassandra_session": free_sess})
+ assert r.status_code == 200, r.text
+ titles = [h["title"] for h in r.json()]
+ assert "fresh" in titles
+ assert "mid" not in titles # 12h ago, beyond 6h
+ assert "old" not in titles
+
+
+def test_paid_user_full_24h(tmp_path):
+ client, _, paid_sess = _build_app(tmp_path)
+ r = client.get("/api/news?since_hours=24",
+ cookies={"cassandra_session": paid_sess})
+ assert r.status_code == 200, r.text
+ titles = [h["title"] for h in r.json()]
+ assert {"fresh", "mid", "old"} <= set(titles)