Files
FabledCurator/extension
bvandeusenandClaude Opus 5 bd92fb46b3
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m7s
test: pin the JS<->Py artist-pattern mirror with a shared sample table (3093)
`PLATFORM_ARTIST_PATTERNS` (extension/lib/platforms.js) and
`_PLATFORM_PATTERNS` (extension_service.py) are two hand-kept copies of one
table whose only guard was the comment "keep in sync by hand; reviewers catch
drift" — the same guarantee manifest.json had before #3069, where deviantart
sat in the manifest for seven weeks after the product dropped it.

Drift here is worse than the manifest case, because the two copies gate
opposite halves of ONE interaction: the JS copy decides whether the "Add to
FC" button appears, the Python copy decides whether the resulting POST is
accepted. JS looser than Py shows a button that 400s; Py looser than JS
silently never offers a button for a URL the backend would take. #1485 (the
Patreon /c/ and /cw/ shapes) was the second of those, and its fix had to be
applied to both files by hand.

The two-runtimes objection to a shared SOURCE file is fair, so this tests the
invariant instead of the source. `extension/test/artist-url-samples.json` is
one table of 25 URL samples — match (with the expected slug) and no_match,
each with a `why` — read by BOTH suites and asserted against each one's own
copy of the patterns. Neither runtime imports the other; a change to one copy
alone turns the other runtime's suite red.

Both halves also assert their own coverage: the sample platforms must equal
the platforms that actually have an artist pattern, and every platform must
have samples in both directions. Without that, deleting a platform's samples
would make the guard pass by testing less. Discord is deliberately in neither
table — it is channel-based and has no creator page to put a button on.

The no_match half asserts `_derive` RAISES rather than merely missing the
platform: it tries every pattern in turn, so a nav page some other platform's
pattern happened to swallow would still be accepted by the backend — the same
defect wearing a different platform name.

Samples live under extension/test/ because that path is excluded from both
the XPI file set and the extension version derivation (packaging.sh:
NOT_PACKAGED_TRACKED and NOT_VERSION_RELEVANT both carry `test/**`), so
adding samples ships no bytes and forces no re-sign.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 21:04:53 -04:00
..

FabledCurator Firefox Extension

Self-hosted Firefox extension that pushes session cookies from supported platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord) into FabledCurator, and lets you add a creator as a Source from their page in one click.

Install (operator)

The signed XPI is bundled into the FC Docker image — :dev and :latest each carry their own channel's build. Open FC → Settings → Maintenance → Browser extension → click "Install Firefox extension". Firefox shows its native install prompt. After installing, open the extension's options page (about:addons → FabledCurator → Preferences) and paste in the FC URL + extension API key shown on the same card.

Develop

cd extension/
npm install --no-save        # web-ext only
npm run lint                 # web-ext lint
npm run test:unit            # vitest — lib/ logic + packaging/version checks
npm run start                # launches Firefox with extension loaded
npm run build                # unsigned XPI in web-ext-artifacts/

Smoke checklist (after every release that touches extension/**)

  • npm run lint passes
  • npm run start loads the extension in a clean Firefox profile
  • Options page accepts FC URL + key, indicator turns green
  • Cookie export: log into patreon.com, click Patreon card → "X cookies exported"
  • Discord token: open discord.com, click Discord card → "Token captured"
  • Add as source: visit patreon.com/, click floating button → toast
  • Subscriptions list: popup → "Sources" tab → list renders
  • Check now: click play icon on source row → no error toast

Versioning — the committed number decides nothing

The shipped version is derived, not committed. scripts/packaging.sh version returns YYYY.M.D.HHMM in UTC: the commit time of the newest change to a packaged extension file. build.yml computes it and stamps it into both manifest.json and package.json at build time. The stamp is never committed — the commit carrying it would itself be a change to the extension, which would move the version again.

So:

  • Editing the version does nothing. All of it is overwritten before web-ext ever reads it. There is no bump to make, and none to forget. There is no hand-set part left either: MAJOR.MINOR went away with milestone 318 step 8.
  • npm run build locally produces an XPI labelled with the committed version, since nothing stamped it. Fine for loading into a test profile; not what ships.

Why the extension is the one artifact that does not zero-pad. Every other FC artifact emits rule 148's YYYY.MM.DD.HHMM. AMO will not take it: Mozilla's grammar for addons.mozilla.org is

^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$

— each segment is the single digit 0 or starts 1-9, so 08 and 0201 are rejected, and at most four segments are allowed. The extension therefore emits the same numbers unpadded: 2026.8.29.201 where the rest of the family says 2026.08.29.0201. Rule 148 already defines comparison as numeric per segment, under which the two are equal, so nothing is reordered by the choice and left-padding each segment recovers the family string exactly. ci.yml's extension-version lane checks the derived string against that regex on every push — the cheap place to find out, because AMO 409s on re-signing and a rejected version is burned for good.

Why commit time and not a commit count: a count is per-branch, so dev and main count different histories of the same code and their versions end up ordered by which branch accumulated more commits rather than by which is newer. Commit time gives both branches the same number for the same source — which is exactly what lets one AMO signature serve both channels (family rule 149, FC issue #3092).

Channels

dev and main each build and sign their own extension, and an install is tied to whichever FC instance it points at — Firefox's static update_url cannot apply here, since every FC install is a different host, so the extension asks its configured backend. The channel therefore IS the instance. Switching channel means repointing the FC URL in options and reinstalling from that host; there is no separate channel setting, and adding one would contradict each server build shipping its own extension.

The channel is reported beside the version, never inside it: /api/extension/manifest answers {"version": "...", "channel": "dev"}. It is optional — an instance that declares none simply omits the key, and the popup, the toolbar tooltip and the Settings card all read exactly as they did before the field existed. Do not be tempted to make it a -dev version suffix: the comparator parses each dotted segment with parseInt, so a suffixed segment reads as 0 and every dev build compares equal to every other, collapsing "no update available" and "I cannot read this version" into one answer.

Release

Nothing to do by hand. Push to dev: build.yml signs the extension if this change moved the version, caches the signed XPI as a Forgejo ext-<version> release, and bundles it into fabledcurator:dev. Merging to main derives the same version, hits that cache, and bundles the byte-identical XPI into :latest with no second AMO call.

AMO refuses to re-sign a version it has already issued, so signing is one-shot per version — which is why the cache exists and why the version must never move backwards.