feat: SubscribeStar joins the membership roster (387 D1)
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s

The second platform through the seam note 3970 contracted, characterized first from a live capture of the account's /subscriptions page (note 3989). The capture lives in the gitignored captures dir; the committed fixture is hand-built with invented values and was verified tag-for-tag against it - card wrappers, both table heads, and every distinct row shape - before any code depended on it.

What the page is, and the three decisions it forced:

The table IS the status. SubscribeStar has no per-row status word: a creator is either in the active_subscriptions card or the cancelled_subscriptions one. The card's data-identifier is stored verbatim as Membership.status and mapped in MEMBERSHIP_STATUS, keyed on the identifier rather than the table class because the cancelled table's class names the same list differently (for-unsubscribed_users).

The creator's numeric data-user-id is the key, not the slug. A slug re-keys when a creator renames; the old row stops appearing; and a disappearance is exactly what reconciliation reads as a lapse. Keyed on the slug, a rename would have told a paying subscriber they had cancelled. The slug rides as vanity, where the identity join already looks for a handle.

Price is kept as text, never parsed into amount_cents. A bare $ names no currency and a page price is not proven to be the charge - 3970 finding 4. Tier names live behind a per-row modal and are not fetched.

Refusals, because SubscribeStar offers nothing like Patreon's meta.pagination.total and every conclusion downstream is drawn from absence. The parser raises when: the active card is missing (auth error on a login/age wall, drift otherwise); a row lacks a numeric creator id or a creator link; anything renders after a card's table; or the page carries a page= link. Both cards are paginatable (app#embed_pagination) and the captured account was too small to show what pagination looks like, so possible pagination is a roster FC cannot prove complete. A loud error on a larger account beats a quiet half-list. A missing cancelled card is not drift, and a creator in both tables is reported once, as active.

Fetched from subscribestar.adult, not the .art the capture came from: FC's requests never clear the .art age wall with the 18+ cookie (1259, 1284). Whether /subscriptions on .adult authenticates exactly as .art did in the browser is untested - if not, the sweep records a visible error and C6 shows its unavailable rung.

The seam leak D1 found. Note 3970 promised a second platform would be one builders line plus the client method. The sweep instead called current_user_id() on every client, which only Patreon's has, so SubscribeStar would have raised AttributeError on the first sweep. roster_user_id probes it with getattr, the same way the sweep already probes iter_memberships.

Two existing tests were passing for the wrong reason and now can fail:
- "a platform that has never been characterised says nothing" named SubscribeStar, and stayed green only because active_patron is not a SubscribeStar word. Now uses hentaifoundry, with a positive SubscribeStar test beside it.
- the freshness test gave SubscribeStar a Patreon word, so the vocabulary excluded it and deleting the freshness gate outright would have left it green. It now uses cancelled_subscriptions, making the gate the only thing that excludes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
2026-09-13 10:53:01 -04:00
co-authored by Claude Opus 5
parent 529d4bff57
commit ef91fcfd26
7 changed files with 532 additions and 9 deletions
+20 -3
View File
@@ -74,8 +74,22 @@ def test_an_unrecognised_status_says_nothing(status):
def test_a_platform_that_has_never_been_characterised_says_nothing():
"""SubscribeStar and FANBOX (D1) inherit silence, not a Patreon guess."""
assert gated_reason("subscribestar", "active_patron") is None
"""An uncharacterised platform inherits silence, not a Patreon guess.
This named SubscribeStar until D1 characterised it (note #3989). Left as it
was, it would have kept passing only because `active_patron` is not a
SubscribeStar word, testing nothing its name claims.
"""
assert gated_reason("hentaifoundry", "active_patron") is None
def test_subscribestar_explains_the_gate_in_its_own_words():
"""SubscribeStar's status is which table the creator sits in, and those
identifiers reach the same three reasons as Patreon's words."""
assert gated_reason("subscribestar", "active_subscriptions") == GATED_TIER
assert gated_reason("subscribestar", "cancelled_subscriptions") == GATED_LAPSED
# Patreon's vocabulary does not leak across platforms.
assert gated_reason("subscribestar", "former_patron") is None
# --- the join, against the database ----------------------------------------
@@ -178,8 +192,11 @@ async def test_one_platforms_fresh_sweep_does_not_vouch_for_anothers(db):
url="https://subscribestar.adult/maewix",
)
await _membership(db, status="former_patron")
# SubscribeStar's REAL word, so that the freshness gate is the only thing
# excluding this membership. With a Patreon word (as this test first had),
# the vocabulary excluded it and removing the gate would have left it green.
await _membership(db, platform="subscribestar", campaign="s1",
status="former_patron",
status="cancelled_subscriptions",
url="https://subscribestar.adult/maewix",
details={"campaign": {"vanity": "maewix"}})
await _synced(db) # patreon only