Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,27 @@ async def require_admin(
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Forbidden")
return user


async def require_premium(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
"""Dependency — raises 403 unless the account has premium = true OR
is_beta = true.

Gates the server-dependent Full Moon value surfaces (ADR 0018). is_beta is
accepted alongside premium so the "first 500 free Full Moon" promo accounts
keep access while the promotion is live. Mirrors require_admin: 401 when the
token carries no ``sub``, 403 when the account is not entitled.
"""
user_id = user.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async with request.app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT premium, is_beta FROM profiles WHERE id = $1", user_id
)
if not row or not (row["premium"] or row["is_beta"]):
raise HTTPException(status_code=403, detail="Forbidden")
return user
38 changes: 33 additions & 5 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
DOMAINS, NORM_MIN_SAMPLE, NORM_REFRESH_DAYS, resolve_norm,
)
from emails import send_witness_assigned, send_witness_completed, send_group_invitation
from deps import require_admin
from deps import require_admin, require_premium
import auth as auth_module
import blog as blog_module
import seo as seo_module
Expand Down Expand Up @@ -680,6 +680,22 @@ async def log_result(
async with _pool.acquire() as conn:
if user_id:
await ensure_profile(conn, user_id, (user or {}).get("email"))
# Full Moon persistence is premium-only (ADR 0018). Free instruments
# (newMoon, firstQuarter) and anonymous posts stay open by design;
# client-side scoring is untouched. premium OR is_beta so promo accounts
# keep access while the "first 500 free" promotion is live. An anonymous
# or non-entitled Full Moon persist is refused (403), so no ungated
# fullMoon row can ever be written.
if body.instrument == "fullMoon":
prow = (
await conn.fetchrow(
"SELECT premium, is_beta FROM profiles WHERE id = $1", user_id
)
if user_id
else None
)
if not prow or not (prow["premium"] or prow["is_beta"]):
raise HTTPException(status_code=403, detail="Forbidden")
row = await conn.fetchrow(
"""
INSERT INTO results
Expand Down Expand Up @@ -749,11 +765,15 @@ async def stripe_webhook(request: Request):
async def create_witness_sessions(
request: Request,
body: CreateSessionsBody,
user: dict = Depends(get_current_user),
user: dict = Depends(require_premium),
):
"""
Creates up to 12 witness sessions for the authenticated user.
Returns an array of { token, name, link }.

Premium-only (ADR 0018): a witness campaign is Full Moon value. premium OR
is_beta (promo accounts included). The witness themselves submit via a public
link and are never gated.
"""
subject_id = user["sub"]
async with _pool.acquire() as conn:
Expand Down Expand Up @@ -889,8 +909,12 @@ async def complete_witness_session(


@app.get("/witness/my-sessions")
async def get_my_sessions(user: dict = Depends(get_current_user)):
"""Returns all witness sessions for the authenticated user, with scores for completed ones."""
async def get_my_sessions(user: dict = Depends(require_premium)):
"""Returns all witness sessions for the authenticated user, with scores for completed ones.

Premium-only (ADR 0018): serving averaged witness results is Full Moon value.
premium OR is_beta (promo accounts included).
"""
subject_id = user["sub"]
async with _pool.acquire() as conn:
rows = await conn.fetch(
Expand Down Expand Up @@ -1147,11 +1171,15 @@ async def decline_group_invitation(
@app.get("/groups/{group_id}/report-data")
async def get_group_report_data(
group_id: str,
user: dict = Depends(get_current_user),
user: dict = Depends(require_premium),
):
"""
Returns report data for all active members of a group.
Fetches profiles + latest Full Moon results in a single JOIN query.

Premium-only (ADR 0018): the Last Quarter team report is Full Moon value.
The premium/is_beta gate is ON TOP OF the existing active-membership check
below — a member without premium/is_beta cannot pull the report.
"""
user_id = user["sub"]
async with _pool.acquire() as conn:
Expand Down
236 changes: 236 additions & 0 deletions api/tests/test_premium_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"""
Server-side Full Moon paywall (ADR 0018, Option A).

Gates the server-dependent Full Moon value surfaces on premium OR is_beta, while
leaving client-side scoring and the free/public flows untouched. No database in
CI, so we drive the endpoints against a fake asyncpg pool (mirrors
test_events.py) and unit-test the shared require_premium dependency directly.

Covered:
- require_premium: non-entitled -> 403, no sub -> 401, premium -> pass,
is_beta -> pass (promo accounts keep access).
- Route wiring: the three gated surfaces depend on require_premium; the
deliberate non-targets do not.
- POST /results fullMoon branch: anon/non-entitled refused, premium/is_beta
persist; free instruments (anon newMoon) stay open.
"""

from __future__ import annotations

import os
import sys
import types

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

os.environ.setdefault("JWT_SECRET", "x" * 48)
os.environ.setdefault("DATABASE_URL", "postgresql://example.invalid/gate_test")

import asyncio # noqa: E402

from fastapi.testclient import TestClient # noqa: E402
from fastapi import HTTPException # noqa: E402
from jose import jwt # noqa: E402

import deps as deps_module # noqa: E402
import main as main_module # noqa: E402


# ── Fakes ────────────────────────────────────────────────────────────────────

class FakeConn:
"""asyncpg-connection stand-in. `profile` is what the premium SELECT returns."""

def __init__(self, profile=None):
self.profile = profile
self.executed: list = []
self.results_inserted = False

async def __aenter__(self):
return self

async def __aexit__(self, *exc):
return False

async def execute(self, query, *args):
self.executed.append((query, args))
return "INSERT 0 1"

async def fetchrow(self, query, *args):
if "SELECT premium, is_beta FROM profiles" in query:
return self.profile
if "INSERT INTO results" in query:
self.results_inserted = True
return {"id": "00000000-0000-0000-0000-000000000001"}
return None

async def fetch(self, query, *args):
return []


class FakePool:
def __init__(self, conn):
self._conn = conn

def acquire(self):
return self._conn


def _install_pool(conn):
pool = FakePool(conn)
main_module._pool = pool
main_module.app.state.pool = pool
return pool


def _token(sub="user-1"):
return jwt.encode({"sub": sub, "aud": "authenticated"}, main_module._JWT_SECRET, algorithm="HS256")


def _auth(sub="user-1"):
return {"Authorization": f"Bearer {_token(sub)}"}


def _client(conn):
_install_pool(conn)
# No context manager -> lifespan (and its real DB connect) is skipped.
return TestClient(main_module.app, raise_server_exceptions=False)


def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)


# ── require_premium (shared dependency) ──────────────────────────────────────

def _fake_request(conn):
pool = FakePool(conn)
return types.SimpleNamespace(app=types.SimpleNamespace(state=types.SimpleNamespace(pool=pool)))


def test_require_premium_allows_premium():
conn = FakeConn(profile={"premium": True, "is_beta": False})
out = _run(deps_module.require_premium(_fake_request(conn), {"sub": "u1"}))
assert out == {"sub": "u1"}


def test_require_premium_allows_beta():
conn = FakeConn(profile={"premium": False, "is_beta": True})
out = _run(deps_module.require_premium(_fake_request(conn), {"sub": "u1"}))
assert out == {"sub": "u1"}


def test_require_premium_refuses_non_entitled():
conn = FakeConn(profile={"premium": False, "is_beta": False})
try:
_run(deps_module.require_premium(_fake_request(conn), {"sub": "u1"}))
assert False, "expected 403"
except HTTPException as e:
assert e.status_code == 403


def test_require_premium_refuses_missing_profile():
conn = FakeConn(profile=None)
try:
_run(deps_module.require_premium(_fake_request(conn), {"sub": "u1"}))
assert False, "expected 403"
except HTTPException as e:
assert e.status_code == 403


def test_require_premium_401_without_sub():
conn = FakeConn(profile={"premium": True, "is_beta": True})
try:
_run(deps_module.require_premium(_fake_request(conn), {}))
assert False, "expected 401"
except HTTPException as e:
assert e.status_code == 401


# ── Route wiring ─────────────────────────────────────────────────────────────

def _route_dep_names(path: str) -> set[str]:
for r in main_module.app.routes:
if getattr(r, "path", "") == path:
names: set[str] = set()

def walk(dep):
for sub in dep.dependencies:
names.add(getattr(sub.call, "__name__", type(sub.call).__name__))
walk(sub)

walk(r.dependant)
return names
raise AssertionError(f"route not found: {path}")


def test_gated_surfaces_require_premium():
for path in ("/witness/sessions", "/witness/my-sessions", "/groups/{group_id}/report-data"):
assert "require_premium" in _route_dep_names(path), path


def test_non_targets_are_not_gated():
# These must NOT be gated: free/own-data reads and the public witness submit.
for path in (
"/me/results",
"/witness/my-contributions",
"/witness/session/{token}/complete",
"/groups",
"/results",
):
assert "require_premium" not in _route_dep_names(path), path


# ── POST /results fullMoon branch ────────────────────────────────────────────

_FM = {"instrument": "fullMoon", "presence": 3, "bond": 3, "discipline": 3, "depth": 3, "vision": 3}
_NM = {"instrument": "newMoon", "presence": 4, "bond": 4, "discipline": 4, "depth": 4, "vision": 4}


def test_fullmoon_anonymous_is_refused():
conn = FakeConn(profile=None)
client = _client(conn)
resp = client.post("/results", json=_FM) # no auth header
assert resp.status_code == 403
assert conn.results_inserted is False


def test_fullmoon_non_premium_is_refused():
conn = FakeConn(profile={"premium": False, "is_beta": False})
client = _client(conn)
resp = client.post("/results", json=_FM, headers=_auth())
assert resp.status_code == 403
assert conn.results_inserted is False


def test_fullmoon_premium_persists():
conn = FakeConn(profile={"premium": True, "is_beta": False})
client = _client(conn)
resp = client.post("/results", json=_FM, headers=_auth())
assert resp.status_code == 200
assert conn.results_inserted is True


def test_fullmoon_beta_persists():
conn = FakeConn(profile={"premium": False, "is_beta": True})
client = _client(conn)
resp = client.post("/results", json=_FM, headers=_auth())
assert resp.status_code == 200
assert conn.results_inserted is True


def test_free_instrument_anonymous_still_open():
conn = FakeConn(profile=None)
client = _client(conn)
resp = client.post("/results", json=_NM) # anonymous newMoon
assert resp.status_code == 200
assert conn.results_inserted is True


def test_gated_endpoint_refuses_non_premium_end_to_end():
# A real gated endpoint returns 403 before its body runs for a non-entitled
# (but authenticated) caller.
conn = FakeConn(profile={"premium": False, "is_beta": False})
client = _client(conn)
resp = client.get("/witness/my-sessions", headers=_auth())
assert resp.status_code == 403
16 changes: 16 additions & 0 deletions docs/architecture/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ slot-farming on the unverified password-signup path.
premium (the grant still ORs with the current value). Accounts predating the
migration were backfilled `TRUE` (no revocation).

## Full Moon premium enforcement (server-side)

Premium is enforced server-side on the **server-dependent** Full Moon value
surfaces (ADR `0018`), while client-side scoring stays untouched. The check
authorizes `premium = TRUE OR is_beta = TRUE` (promo accounts keep access).

- Reusable dependency `require_premium` in `api/deps.py` (mirrors
`require_admin`: 401 without `sub`, 403 when not entitled) gates
`POST /witness/sessions`, `GET /witness/my-sessions`, and
`GET /groups/{id}/report-data` (the last on top of its active-membership check).
- `POST /results` gates **inside the `fullMoon` branch only** — free instruments
and anonymous posts stay open, so no ungated Full Moon row can be written.
- Left open by design: the public witness submission
(`/witness/session/{token}` + `.../complete`), `/witness/my-contributions`,
`/me/results`, and `POST /groups`. See ADR 0018 for the full map and rationale.

### Google OAuth

- Direct OAuth 2.0 flow (no Supabase middle layer).
Expand Down
Loading
Loading