The Patreon roster syncs, the favicon shows, and the logo sits behind every page #252

Merged
bvandeusen merged 4 commits from dev into main 2026-09-13 18:22:00 -04:00
11 changed files with 244 additions and 86 deletions
+1 -1
View File
@@ -47,12 +47,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, MembershipSync, PlatformMembership, Source from ..models import Artist, MembershipSync, PlatformMembership, Source
from .membership_roster import ( from .membership_roster import (
get_sync_state, get_sync_state,
has_paid_access,
identity_keys_for_source, identity_keys_for_source,
pair_sources_with_memberships, pair_sources_with_memberships,
roster_is_fresh, roster_is_fresh,
url_tail, url_tail,
) )
from .native_ingest_common import has_paid_access
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first — # Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
# the UI renders a different sentence per basis, because collapsing them into # the UI renders a different sentence per basis, because collapsing them into
+5 -71
View File
@@ -20,8 +20,10 @@ own word — `active_patron`, not some normalised FC value. The mapping from
those words to FC's meaning is a read-site concern and belongs in code that can those words to FC's meaning is a read-site concern and belongs in code that can
be corrected without a migration, because the vocabulary comes from whatever be corrected without a migration, because the vocabulary comes from whatever
each platform says and will be discovered per platform rather than designed up each platform says and will be discovered per platform rather than designed up
front. `MEMBERSHIP_STATUS` below is a place for that knowledge to accumulate as front. `native_ingest_common.MEMBERSHIP_STATUS` is where that knowledge
platforms are characterised; it is deliberately empty of guesses today. accumulates as platforms are characterised, and it holds no guesses. It lives
there rather than here because platform clients need it, and a client may not
import this module (test_gated_reason.py).
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,78 +37,10 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import MembershipSync, PlatformMembership, Source from ..models import MembershipSync, PlatformMembership, Source
from .native_ingest_common import has_paid_access
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Platform word -> whether the account currently has paid access.
#
# Every entry here must come from a CHARACTERISED response, never from API docs
# or a plausible guess — project rule 130, and inventing a status before seeing
# it in a real payload is exactly the failure it names.
#
# patreon: from a live capture of the operator's own session, 2026-09-10
# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and
# only those two are here.
#
# `declined_patron` is deliberately ABSENT even though it looks obviously
# right. It appears in the request's `filter[membership_type]`, and the capture
# proved that filter is NOT the same vocabulary as the attribute — a row
# selected by the filter as `free_member` came back with
# `patron_status: former_patron`, a word the filter does not contain. Reading
# the filter as an enum is the specific mistake the capture caught; adding
# `declined_patron` on the strength of it would be repeating that mistake one
# step later.
#
# Unknown words are NOT an error: an unrecognised status means the roster
# records evidence it cannot yet interpret, which is a better state than
# dropping the row or asserting a meaning for it.
#
# subscribestar: from a live capture of the account's /subscriptions page,
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
# a membership's state is which of two tables it sits in — so the "word" stored
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
# the whole vocabulary; there is nothing further to characterise later.
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
"patreon": {
"active_patron": True,
"former_patron": False,
},
"subscribestar": {
"active_subscriptions": True,
"cancelled_subscriptions": False,
},
}
def has_paid_access(
platform: str, status: str | None, *, is_free_member: bool = False,
) -> bool | None:
"""Does this membership mean the account currently PAYS for access?
Returns None for a status this code has not been taught, which callers must
treat as "unknown" rather than as False. The difference matters: False says
the operator has lost access, and asserting that from an unrecognised word
would tell them to cancel a source they are still paying for.
`is_free_member` is a second axis, not a status, and that is Patreon's
design rather than ours: the capture shows a free follow expressed as a
boolean alongside `patron_status`, so a "current" membership can still be
one nobody is paying for. Taking status alone would report a free follower
as a paying patron, and C4 would then never offer to clean it up.
(Honest limit: the capture contains no ACTIVE free member, so it cannot
demonstrate the two axes coming apart. The separation is what the payload's
shape says; the sample only shows it is possible, not that it happens.)
"""
if status is None:
return None
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
if known is None:
return None
if not known:
return False
return not is_free_member
async def touch_membership( async def touch_membership(
session: AsyncSession, session: AsyncSession,
+79 -1
View File
@@ -231,7 +231,7 @@ class Membership:
`status` carries the PLATFORM's own word, verbatim and unmapped `status` carries the PLATFORM's own word, verbatim and unmapped
(`active_patron`, `former_patron`, ...). Deciding what it means is the read (`active_patron`, `former_patron`, ...). Deciding what it means is the read
site's job — `membership_roster.has_paid_access` — precisely so an site's job — `has_paid_access`, below — precisely so an
unrecognised word records as evidence rather than as a decision. unrecognised word records as evidence rather than as a decision.
`is_free_member` is SEPARATE from status and must stay that way. Patreon `is_free_member` is SEPARATE from status and must stay that way. Patreon
@@ -396,3 +396,81 @@ class BaseNativeDownloader:
sidecar_path = media_path.with_suffix(".json") sidecar_path = media_path.with_suffix(".json")
sidecar_path.write_text(json.dumps(data, indent=2)) sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path return sidecar_path
# --- membership status vocabulary (#387) ------------------------------------
#
# Lives here, beside `Membership`, rather than in `membership_roster`. It is
# pure platform knowledge with no database behind it, and the platform clients
# need it too. Patreon's must tell a lapsed membership to a deleted creator
# (skippable) from a paid one it cannot attribute (drift), and a client may not
# import `membership_roster`: test_gated_reason.py forbids any fetch path from
# reaching the roster, so the roster can explain a skip but never cause one.
#
# Platform word -> whether the account currently has paid access.
#
# Every entry here must come from a CHARACTERISED response, never from API docs
# or a plausible guess — project rule 130, and inventing a status before seeing
# it in a real payload is exactly the failure it names.
#
# patreon: from a live capture of the operator's own session, 2026-09-10
# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and
# only those two are here.
#
# `declined_patron` is deliberately ABSENT even though it looks obviously
# right. It appears in the request's `filter[membership_type]`, and the capture
# proved that filter is NOT the same vocabulary as the attribute — a row
# selected by the filter as `free_member` came back with
# `patron_status: former_patron`, a word the filter does not contain. Reading
# the filter as an enum is the specific mistake the capture caught; adding
# `declined_patron` on the strength of it would be repeating that mistake one
# step later.
#
# Unknown words are NOT an error: an unrecognised status means the roster
# records evidence it cannot yet interpret, which is a better state than
# dropping the row or asserting a meaning for it.
#
# subscribestar: from a live capture of the account's /subscriptions page,
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
# a membership's state is which of two tables it sits in — so the "word" stored
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
# the whole vocabulary; there is nothing further to characterise later.
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
"patreon": {
"active_patron": True,
"former_patron": False,
},
"subscribestar": {
"active_subscriptions": True,
"cancelled_subscriptions": False,
},
}
def has_paid_access(
platform: str, status: str | None, *, is_free_member: bool = False,
) -> bool | None:
"""Does this membership mean the account currently PAYS for access?
Returns None for a status this code has not been taught, which callers must
treat as "unknown" rather than as False. The difference matters: False says
the operator has lost access, and asserting that from an unrecognised word
would tell them to cancel a source they are still paying for.
`is_free_member` is a second axis, not a status, and that is Patreon's
design rather than ours: the capture shows a free follow expressed as a
boolean alongside `patron_status`, so a "current" membership can still be
one nobody is paying for. Taking status alone would report a free follower
as a paying patron, and C4 would then never offer to clean it up.
(Honest limit: the capture contains no ACTIVE free member, so it cannot
demonstrate the two axes coming apart. The separation is what the payload's
shape says; the sample only shows it is possible, not that it happens.)
"""
if status is None:
return None
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
if known is None:
return None
if not known:
return False
return not is_free_member
+34 -2
View File
@@ -58,6 +58,7 @@ from .native_ingest_common import (
NativeDriftError, NativeDriftError,
NativeIngestError, NativeIngestError,
basename_from_url, basename_from_url,
has_paid_access,
make_session, make_session,
retry_after_seconds, retry_after_seconds,
) )
@@ -631,7 +632,25 @@ class PatreonClient:
"cannot tell a complete roster from a truncated one" "cannot tell a complete roster from a truncated one"
) )
def _membership(self, member: dict, index: dict) -> Membership: def _membership(self, member: dict, index: dict) -> Membership | None:
"""One member row as a Membership, or None for a row the roster can skip.
The one skippable row is a LAPSED membership whose creator no longer
exists. The live roster (note #3886, CORRECTION 3) returned 104 rows,
because FC sends no membership-type filter and so gets lapses going back
years. One of them, a membership that ended in 2017, carried no
`campaign` relationship at all: the key is absent, not null, and its
reward names no campaign either. The creator's page is gone.
Raising on that row made the whole roster unusable over one membership
nobody can act on. Skipping it changes no conclusion. A lapsed
membership already means "not paying", absence means the same, and no
Source can be matched to a campaign that no longer has an id.
The refusal stays for every other row. An active or unrecognised
membership without a creator is something FC cannot vouch for, and
dropping it would read downstream as a cancellation.
"""
attrs = member.get("attributes") or {} attrs = member.get("attributes") or {}
if "patron_status" not in attrs: if "patron_status" not in attrs:
raise PatreonDriftError( raise PatreonDriftError(
@@ -640,6 +659,17 @@ class PatreonClient:
campaign_ids = self._related_ids(member, "campaign") campaign_ids = self._related_ids(member, "campaign")
if not campaign_ids: if not campaign_ids:
paid = has_paid_access(
"patreon", attrs.get("patron_status"),
is_free_member=bool(attrs.get("is_free_member")),
)
if paid is False:
log.info(
"Patreon roster: skipping a lapsed membership with no campaign "
"(creator deleted); status=%s access_expires_at=%s",
attrs.get("patron_status"), attrs.get("access_expires_at"),
)
return None
raise PatreonDriftError( raise PatreonDriftError(
"Patreon member resource has no campaign relationship — a " "Patreon member resource has no campaign relationship — a "
"membership we cannot attribute to a creator is not usable" "membership we cannot attribute to a creator is not usable"
@@ -700,7 +730,9 @@ class PatreonClient:
index = self._transform(response) index = self._transform(response)
rows = [m for m in (response.get("data") or []) if isinstance(m, dict)] rows = [m for m in (response.get("data") or []) if isinstance(m, dict)]
for member in rows: for member in rows:
yield self._membership(member, index) membership = self._membership(member, index)
if membership is not None:
yield membership
seen += len(rows) seen += len(rows)
total = int(response["meta"]["pagination"]["total"] or 0) total = int(response["meta"]["pagination"]["total"] or 0)
+1 -1
View File
@@ -316,7 +316,7 @@ _ROSTER_URL = f"{_ROSTER_BASE}/subscriptions"
# `data-identifier`, the one vocabulary that names a state: the table class # `data-identifier`, the one vocabulary that names a state: the table class
# inside the cancelled card says `for-unsubscribed_users`, a different word for # inside the cancelled card says `for-unsubscribed_users`, a different word for
# the same list (note #3989, CORRECTION 1). The identifier is stored verbatim as # the same list (note #3989, CORRECTION 1). The identifier is stored verbatim as
# Membership.status and mapped in membership_roster.MEMBERSHIP_STATUS. # Membership.status and mapped in native_ingest_common.MEMBERSHIP_STATUS.
_ROSTER_ACTIVE = "active_subscriptions" _ROSTER_ACTIVE = "active_subscriptions"
_ROSTER_CANCELLED = "cancelled_subscriptions" _ROSTER_CANCELLED = "cancelled_subscriptions"
+4 -1
View File
@@ -15,7 +15,10 @@
Colours are theme tokens (frontend/src/theme/fabled-tokens.js): obsidian Colours are theme tokens (frontend/src/theme/fabled-tokens.js): obsidian
plate, accent gold. The plate is kept here (unlike logo.svg) so the tab plate, accent gold. The plate is kept here (unlike logo.svg) so the tab
icon is self-contained against any browser chrome; on the nav it is icon is self-contained against any browser chrome; on the nav it is
invisible because it matches --fc-chrome-rgb exactly. --> invisible because it matches the fc-chrome-rgb custom property exactly.
No double hyphen may appear inside this comment: XML forbids it, and a
browser refuses to render an SVG that does not parse (it happened once —
tests/test_public_svgs.py). -->
<rect width="32" height="32" rx="6" fill="#14171A"/> <rect width="32" height="32" rx="6" fill="#14171A"/>
<rect x="6.2" y="4.2" width="19.6" height="23.6" rx="1.4" <rect x="6.2" y="4.2" width="19.6" height="23.6" rx="1.4"
fill="none" stroke="#A87338" stroke-width="2.4"/> fill="none" stroke="#A87338" stroke-width="2.4"/>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+16
View File
@@ -17,6 +17,22 @@ const route = useRoute()
<style scoped> <style scoped>
.fc-content { .fc-content {
/* The full brand mark as one large, faint backdrop behind every page,
pinned to the viewport so content scrolls over it. Opaque surfaces
(cards, the nav) cover it; it shows in the gutters and on bare page
ground. The series reader is immersive and skips the shell, so reading
is never drawn over it.
Faded by laying the page colour over it at 94%, NOT with `opacity` on an
overlay element: an overlay needs this element to be z-indexed above it,
which makes all page content one stacking context under the nav and can
trap an in-page overlay beneath it. A background changes no stacking.
The mark is gold and parchment, close to the text colours, so it has to
stay this faint to keep text over it readable. */
background:
linear-gradient(rgba(var(--v-theme-background), 0.94), rgba(var(--v-theme-background), 0.94)),
url('/logo.svg') center / min(88vmin, 1100px) no-repeat;
background-attachment: fixed;
min-height: 100vh; min-height: 100vh;
/* NO padding-top: the TopNav is position:sticky, so it already reserves its /* NO padding-top: the TopNav is position:sticky, so it already reserves its
own space in the v-app flex column — content flows directly below it. The own space in the v-app flex column — content flows directly below it. The
+2 -5
View File
@@ -10,11 +10,8 @@ import pytest
from sqlalchemy import select from sqlalchemy import select
from backend.app.models import PlatformMembership from backend.app.models import PlatformMembership
from backend.app.services.membership_roster import ( from backend.app.services.membership_roster import touch_membership
MEMBERSHIP_STATUS, from backend.app.services.native_ingest_common import MEMBERSHIP_STATUS, has_paid_access
has_paid_access,
touch_membership,
)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
+60 -2
View File
@@ -239,14 +239,72 @@ def test_a_missing_data_list_is_drift(client, payload):
list(client.iter_memberships(user_id="1")) list(client.iter_memberships(user_id="1"))
def test_a_member_with_no_campaign_is_drift(client, payload): def _index_of(payload, status):
return next(
i for i, m in enumerate(payload["data"])
if m["attributes"]["patron_status"] == status
)
@pytest.mark.parametrize("shape", ["null", "absent"])
def test_an_active_member_with_no_campaign_is_drift(client, payload, shape):
"""A PAID membership FC cannot attribute must refuse the roster. Dropping it
would read downstream as the operator having cancelled it."""
mangled = json.loads(json.dumps(payload)) mangled = json.loads(json.dumps(payload))
mangled["data"][0]["relationships"]["campaign"] = {"data": None} rels = mangled["data"][_index_of(mangled, "active_patron")]["relationships"]
if shape == "null":
rels["campaign"] = {"data": None}
else:
del rels["campaign"]
client._request = lambda *a, **k: mangled client._request = lambda *a, **k: mangled
with pytest.raises(PatreonDriftError, match="campaign"): with pytest.raises(PatreonDriftError, match="campaign"):
list(client.iter_memberships(user_id="1")) list(client.iter_memberships(user_id="1"))
def test_an_unrecognised_status_with_no_campaign_is_drift(client, payload):
"""Only a status KNOWN to mean "not paying" may be skipped. An unknown word
could be a paid membership (has_paid_access returns None for it)."""
mangled = json.loads(json.dumps(payload))
row = mangled["data"][_index_of(mangled, "active_patron")]
row["attributes"]["patron_status"] = "some_new_status"
del row["relationships"]["campaign"]
client._request = lambda *a, **k: mangled
with pytest.raises(PatreonDriftError, match="campaign"):
list(client.iter_memberships(user_id="1"))
def test_a_lapsed_member_whose_creator_is_gone_is_skipped(client, payload):
"""The live roster's shape (note #3886, CORRECTION 3). A membership that
lapsed in 2017 came back with no `campaign` key at all, because the creator's
page no longer exists. It used to fail the whole roster. Now it is the only
row missing, and every other row still arrives."""
mangled = json.loads(json.dumps(payload))
lapsed = _index_of(mangled, "former_patron")
del mangled["data"][lapsed]["relationships"]["campaign"]
client._request = lambda *a, **k: mangled
rows = list(client.iter_memberships(user_id="1"))
assert len(rows) == len(payload["data"]) - 1
assert all(r.campaign_id for r in rows)
assert all(r.status == "active_patron" for r in rows)
def test_skipping_does_not_end_paging_early(client, payload):
"""Paging counts the rows the SERVER sent, not the ones kept. Counting kept
rows would re-request an offset that was already read, or stop one page
short, whenever a row is skipped."""
first = json.loads(json.dumps(payload))
del first["data"][_index_of(first, "former_patron")]["relationships"]["campaign"]
total = 2 * len(payload["data"])
first["meta"]["pagination"]["total"] = total
second = json.loads(json.dumps(payload))
second["meta"]["pagination"]["total"] = total
calls = _serve(client, first, second)
rows = list(client.iter_memberships(user_id="1"))
assert len(calls) == 2
assert calls[1][1]["page[offset]"] == str(len(payload["data"]))
assert len(rows) == total - 1
def test_a_member_with_no_patron_status_is_drift(client, payload): def test_a_member_with_no_patron_status_is_drift(client, payload):
mangled = json.loads(json.dumps(payload)) mangled = json.loads(json.dumps(payload))
del mangled["data"][0]["attributes"]["patron_status"] del mangled["data"][0]["attributes"]["patron_status"]
+40
View File
@@ -0,0 +1,40 @@
"""Every SVG the browser loads from `frontend/public/` must parse as XML.
A browser renders an SVG used as an image only if it is well-formed XML, and
when it is not, nothing reports it: no console error in most browsers, no
failed build, no failed request — the icon is simply blank. `favicon.svg`
shipped that way (merge #251) because its comment contained a CSS custom
property name, and `--` is illegal inside an XML comment. Both the tab icon and
the nav brand mark went missing, and only a person looking at the page noticed.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
PUBLIC = Path(__file__).resolve().parent.parent / "frontend" / "public"
SVGS = sorted(PUBLIC.rglob("*.svg"))
def test_the_public_dir_has_svgs_to_check():
"""Guards the guard: a moved directory would otherwise pass vacuously."""
assert {p.name for p in SVGS} >= {"favicon.svg", "logo.svg"}
@pytest.mark.parametrize("svg", SVGS, ids=lambda p: p.name)
def test_svg_is_well_formed_xml(svg):
root = ET.parse(svg).getroot()
assert root.tag == "{http://www.w3.org/2000/svg}svg"
def test_the_parser_rejects_the_shape_that_broke_the_favicon():
"""Positive control: the exact defect must fail this parser, or the
parametrized test above proves nothing."""
broken = (
'<svg xmlns="http://www.w3.org/2000/svg">'
"<!-- matches --fc-chrome-rgb exactly --></svg>"
)
with pytest.raises(ET.ParseError):
ET.fromstring(broken)
+2 -2
View File
@@ -22,8 +22,8 @@ from types import SimpleNamespace
import pytest import pytest
from backend.app.services.membership_roster import has_paid_access, roster_user_id from backend.app.services.membership_roster import roster_user_id
from backend.app.services.native_ingest_common import Membership from backend.app.services.native_ingest_common import Membership, has_paid_access
from backend.app.services.subscribestar_client import ( from backend.app.services.subscribestar_client import (
SubscribeStarAuthError, SubscribeStarAuthError,
SubscribeStarClient, SubscribeStarClient,