From ce6e0f85616874f13de5f83caeeefd6faaebd681 Mon Sep 17 00:00:00 2001 From: miquelmatoses Date: Tue, 14 Jul 2026 16:53:47 +0200 Subject: [PATCH 1/2] feat(api): enforce Full Moon premium server-side (ADR 0018, Option A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paywall was UI-only; POST /results accepted fullMoon from anyone. Gate the server-dependent value surfaces on premium OR is_beta (promo accounts keep access), leaving client-side scoring and free/public flows untouched. - deps.require_premium: reusable dependency mirroring require_admin (401 no sub, 403 not entitled), authorizes premium OR is_beta. - Gated: POST /witness/sessions, GET /witness/my-sessions, GET /groups/{id}/report-data (on top of membership). - POST /results: inline gate INSIDE the fullMoon branch only — free instruments and anonymous posts stay open; anon/non-entitled fullMoon persist -> 403. - Left open by design: public witness submit, my-contributions, /me/results, POST /groups. No migration (handler logic only). ADR 0018 Accepted; auth.md updated. Co-Authored-By: Claude Opus 4.8 --- api/deps.py | 24 ++ api/main.py | 38 ++- api/tests/test_premium_gate.py | 236 ++++++++++++++++++ docs/architecture/auth.md | 16 ++ .../0018-fullmoon-server-side-paywall.md | 67 +++++ 5 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 api/tests/test_premium_gate.py create mode 100644 docs/decisions/0018-fullmoon-server-side-paywall.md diff --git a/api/deps.py b/api/deps.py index e21434040..89b919716 100644 --- a/api/deps.py +++ b/api/deps.py @@ -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 diff --git a/api/main.py b/api/main.py index 0e7323d56..d7cc05625 100644 --- a/api/main.py +++ b/api/main.py @@ -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 @@ -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 @@ -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: @@ -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( @@ -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: diff --git a/api/tests/test_premium_gate.py b/api/tests/test_premium_gate.py new file mode 100644 index 000000000..d82f65c14 --- /dev/null +++ b/api/tests/test_premium_gate.py @@ -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 diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md index 9adffdd7f..9de13db61 100644 --- a/docs/architecture/auth.md +++ b/docs/architecture/auth.md @@ -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). diff --git a/docs/decisions/0018-fullmoon-server-side-paywall.md b/docs/decisions/0018-fullmoon-server-side-paywall.md new file mode 100644 index 000000000..1d0e249cb --- /dev/null +++ b/docs/decisions/0018-fullmoon-server-side-paywall.md @@ -0,0 +1,67 @@ +# ADR 0018: Server-side enforcement of the Full Moon paywall + +Status: Accepted +Date: 2026-07-14 +Deciders: Miquel + +## Context + +The Full Moon paywall was enforced only in the frontend (`FullMoonPage.jsx`, +`gateState` keyed on `profile.premium`). A read-only investigation on +2026-07-13/14 mapped the server-side enforcement of every premium-value surface +and found them all open: `POST /results` accepted `instrument=fullMoon` from any +caller — including anonymous — with no premium check, and the witness and team +surfaces were gated only on authentication/membership, never on premium. + +Cèrcol scores all instruments client-side by design — a documented product value +and privacy promise ("no data sent to the server during assessment"). Full Moon +items ship in the JS bundle and the report renders client-side. A server-side +gate therefore cannot stop a technical user from taking the client-bundled test +and viewing their own one-off report; moving scoring server-side would break the +privacy promise. The gateable value is the part that is **server-dependent**: +persisting/serving account-linked Full Moon data, witness aggregation, and team +reports. + +## Decision + +Option A: enforce premium server-side on the server-dependent Full Moon value +surfaces; leave client-side scoring and the anonymous one-off self-report render +untouched. The check authorizes `premium = TRUE OR is_beta = TRUE`, so the +"first 500 free Full Moon" promo accounts keep access while the promotion is +live. Implemented as a reusable `require_premium` dependency in `api/deps.py` +(mirroring `require_admin`: 401 without `sub`, 403 when not entitled), plus one +inline check inside the `POST /results` fullMoon branch. + +### Enforcement map + +Gated (premium OR is_beta): + +| Surface | Where | How | +|---|---|---| +| `POST /witness/sessions` | create a witness campaign | `Depends(require_premium)` | +| `GET /witness/my-sessions` | serve averaged witness results | `Depends(require_premium)` | +| `GET /groups/{id}/report-data` | Last Quarter team report | `Depends(require_premium)`, on top of the existing active-membership 403 | +| `POST /results` (`instrument == fullMoon` only) | persist a Full Moon result | inline `premium OR is_beta` check; anonymous/non-entitled → 403 | + +Deliberate non-targets (left open — gating them would break legitimate flows): + +| Surface | Why left open | +|---|---| +| `POST /results` for `newMoon` / `firstQuarter` (incl. anonymous) | free instruments; the fullMoon gate is scoped to that branch only | +| `GET /witness/session/{token}`, `POST /witness/session/{token}/complete` | the third-party witness submits via a public link and does not pay | +| `GET /witness/my-contributions` | a user viewing what they contributed as a witness; not Full Moon value | +| `GET /me/results`, `DELETE /me/results/{id}` | act on the user's own rows incl. free instruments; gating persistence in `POST /results` already prevents non-entitled fullMoon rows from existing | +| `POST /groups` | creating a group is harmless without the (now gated) report | + +## Consequences + +- The server-dependent Full Moon surfaces are enforced server-side; an + anonymous or non-entitled caller can no longer persist a Full Moon result, + create a witness campaign, pull averaged witness results, or pull a team + report. +- Client-side scoring and the anonymous one-off self-report render stay open by + design — the privacy promise is preserved and can be reaffirmed explicitly. +- `is_beta` is accepted alongside `premium`, so no promo account is broken while + the "first 500 free" promotion is live. When Full Moon is charged for again, + no code change is required — paid `premium` accounts already pass. +- No database migration: this is handler logic only. From 86f768064bea84a6e0ed194ebbf5079868d8c0d3 Mon Sep 17 00:00:00 2001 From: miquelmatoses Date: Tue, 14 Jul 2026 16:56:07 +0200 Subject: [PATCH 2/2] docs(adr): match 0018 to the ADR template (metadata + Alternatives/Related) Co-Authored-By: Claude Opus 4.8 --- .../0018-fullmoon-server-side-paywall.md | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/decisions/0018-fullmoon-server-side-paywall.md b/docs/decisions/0018-fullmoon-server-side-paywall.md index 1d0e249cb..efef001ff 100644 --- a/docs/decisions/0018-fullmoon-server-side-paywall.md +++ b/docs/decisions/0018-fullmoon-server-side-paywall.md @@ -1,8 +1,9 @@ # ADR 0018: Server-side enforcement of the Full Moon paywall -Status: Accepted -Date: 2026-07-14 -Deciders: Miquel +- **Number**: 0018 +- **Title**: Server-side enforcement of the Full Moon paywall (Option A) +- **Status**: Accepted +- **Date**: 2026-07-14 ## Context @@ -53,15 +54,35 @@ Deliberate non-targets (left open — gating them would break legitimate flows): | `GET /me/results`, `DELETE /me/results/{id}` | act on the user's own rows incl. free instruments; gating persistence in `POST /results` already prevents non-entitled fullMoon rows from existing | | `POST /groups` | creating a group is harmless without the (now gated) report | +## Alternatives considered + +- **Option B — move Full Moon scoring server-side for the paid tier.** Stop + shipping the item set and scoring to non-premium clients; compute behind a + premium gate. Rejected: breaks "no data sent to the server during assessment", + is a large change to the core scoring architecture, and weakens a documented + privacy value and GEO selling point. +- **Option C — accept the current porousness, keep the UI-only gate.** Rejected: + the enforcement map showed the server-dependent surfaces (witness aggregation, + team reports, result persistence) were genuinely open, so "do nothing" leaves + real value ungated, not just the inherently-open self-report render. + ## Consequences -- The server-dependent Full Moon surfaces are enforced server-side; an - anonymous or non-entitled caller can no longer persist a Full Moon result, - create a witness campaign, pull averaged witness results, or pull a team - report. +- The server-dependent Full Moon surfaces are enforced server-side; an anonymous + or non-entitled caller can no longer persist a Full Moon result, create a + witness campaign, pull averaged witness results, or pull a team report. - Client-side scoring and the anonymous one-off self-report render stay open by design — the privacy promise is preserved and can be reaffirmed explicitly. - `is_beta` is accepted alongside `premium`, so no promo account is broken while the "first 500 free" promotion is live. When Full Moon is charged for again, no code change is required — paid `premium` accounts already pass. - No database migration: this is handler logic only. + +## Related + +- ADR [0016](0016-backend-db-pool-access-unification.md) — the + `request.app.state.pool` access pattern `require_premium` reuses. +- `docs/architecture/auth.md` — records where Full Moon premium is enforced and + the `require_premium` / `require_admin` dependencies. +- Builds on the beta/premium grant (`ensure_profile`) and the email-verification + gate that protects the promo slot count.