-
Notifications
You must be signed in to change notification settings - Fork 0
Moderation
Sensitive-content moderation built ON the shipped vote-consensus system (Printing-Tags) — extended, not forked. Four pieces: Discord-authenticated moderators, a sensitive-tag class whose resolution requires a privileged co-sign, a card report button feeding an audit trail, and a moderator-only approval queue. One deliberately small consequence: cards resolved as NSFW stay out of search by default, with a visible opt-in toggle.
Ordinary voting is untouched. Anyone can still vote on any tag, anonymously,
exactly as before — including the sensitive ones. What changes is what a
crowd's consensus is allowed to do: for a tag marked sensitive, a consensus
that clears every normal threshold parks as pending_approval instead of
resolving, until a privileged vote (a moderator's or an admin's) backs the
winning side. Approval isn't a special action — it's an ordinary vote that
happens to be privileged, so the pending → resolved transition runs through
the exact same consensus pass, Card.tags merge, and Elasticsearch reindex
as any other resolution.
Membership of the MODERATORS_GROUP_NAME Django auth group (default
"Moderators") — that's the entire definition, checked at resolution time
(cardpicker/moderation.py), so revoking a moderator retroactively
de-privileges every vote they ever cast. Logging in with Discord grants
nothing by itself.
The grant mechanism is deliberately pluggable — everything consumes the group
through is_moderator / get_moderator_user_ids. The sketched follow-up for
a federation-wide moderator roster: add the guilds.members.read OAuth scope
and sync group membership from a role (e.g. @Moderator) in the project's
Discord server at login, so every instance independently verifies the same
authority and granting/revoking is one Discord role assignment. The portable
identity is the Discord user id, which allauth already stores in
SocialAccount.uid — no schema work needed when that lands. Note the
complementary path: cross-instance moderation effects travel via federated
verdict exchange (Federation-v1, v1.1), which needs no cross-instance
logins at all.
- Configured entirely from env:
DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET. Absent (e.g. dev) = the provider app isn't installed,/accounts/discord/login/404s, whoami reportsdiscordEnabled: false, nothing crashes. App-in-settings credentials (SOCIALACCOUNT_PROVIDERS) — noSocialAppDB row, nodjango.contrib.sites. -
GET 2/whoami/→{authenticated, username, moderator, discordEnabled, loginUrl, logoutUrl}. Frontend gating is presentation only; the moderation endpoint enforces the group server-side. - Login/logout links carry
?next=<frontend URL>and round-trip back;accounts/adapter.py::FrontendRedirectAccountAdaptervalidatesnextagainst the hosts inCORS_ALLOWED_ORIGINS, so the redirect allowlist can never drift from the CORS one.ACCOUNT_LOGOUT_ON_GET=Trueis a deliberate tradeoff: logout works as a plain link (needed for the round-trip), and the worst a hostile page can do is force a logout — a nuisance, not an escalation. - Cross-origin session: the cookie must ride from the frontend origin to
api.proxyprints.ca, so production needsSESSION_COOKIE_SAMESITE=None,SESSION_COOKIE_SECURE=True,CORS_ALLOW_CREDENTIALS=True(defaults suit same-site localhost dev). Only fetches that opt intocredentials:'include'are affected — whoami, reportCard, and every moderationQueue/moderationDrives*/ moderationRemoveCard/moderationRemoveDrive call (plus the moderation queue's approve/reject votes); every anonymous surface is byte-identical.
Every API endpoint here is @csrf_exempt because the primary clients are
anonymous cross-origin browsers with no Django CSRF cookie to round-trip —
votes are keyed by a client-generated anonymous_id, not a session. That was
safe while nothing read request.user. The moderator session changes that:
a SameSite=None cookie plus csrf_exempt POSTs would let any website forge
privileged votes from a logged-in moderator's browser.
cardpicker/security.py::reject_untrusted_origin closes exactly that hole:
browsers unconditionally attach an Origin header to cross-origin POSTs and
a page cannot forge it, so POSTs whose Origin is present but not in
CORS_ALLOWED_ORIGINS (∪ the backend's own origin) are rejected with 403.
Applied to every session-consuming POST (the three vote submit views,
reportCard, moderationQueue, moderationDrives, moderationDriveCards,
moderationRemoveCard, moderationRemoveDrive). Non-browser clients send no Origin at all
and keep exactly today's trust level; GET endpoints (whoami) change no state
and need nothing.
-
AbstractWeightedVote.user— nullable FK set (alongsideanonymous_id, never instead of it) when the submitting request carried an authenticated session. One additive migration covers all three vote tables. -
VoteTuple.is_privilegedmirrors theis_human_backedpattern: computed by the tag-consensus wrappers as "source==admin OR vote.user currently in the moderators group". Privileged votes weighVOTE_PRIVILEGED_WEIGHT(default = the admin weight, 5) via max() with their source weight — a lone moderator clears the threshold like a lone admin. -
resolve_weighted_consensus(..., require_privileged=True): a winner that clears weight/share/human-backed but has no privileged vote in the winning group returns thePENDING_PRIVILEGEDsentinel instead of the key. In-group, not merely present-among-the-votes: a moderator voting against the crowd must not count as the co-sign that lets the crowd's outcome through (their heavier vote usually flips or contests the result through the normal math anyway). Persisted aspending_approvalinCard.tag_vote_statuses;Card.tagsuntouched, so pending has zero search consequences. - Wired only for
Tag.moderation_class == "sensitive"and through both resolution paths: the interactive one (resolve_tag/resolve_and_persist_tag_votes) and the batched re-scan overlay (get_resolved_tag_overlay) — skipping the second would let a scheduledupdate_databasere-scan apply the very change the interactive path held for approval. Standard tags are byte-identical (the gate parameter defaults off; no pre-existing test file was modified — the untouched consensus suites are the regression proof). - Privileged weight is wired for tag consensus only this stage; printing and
artist votes record
user(the field lives on the shared abstract base) but their weight math is untouched. Extending them later needs no migration.
Tag.moderation_class (standard | sensitive, default standard).
manage.py seed_sensitive_tags seeds four (command, not data migration —
same rationale as the other taxonomies, see Printing-Tags):
| name | display name | report reason |
|---|---|---|
NSFW |
NSFW | nsfw |
low-res |
Low quality | low_quality |
incorrect-info |
Incorrect card info | wrong_card |
appropriate-bleed |
Appropriate Bleed | — (no chip) |
appropriate-bleed is deliberately the positive framing ("verified to
include the full bleed margin required for printing") rather than a negative
"missing-bleed": upstream drives require appropriate bleed on every card, so
the state worth verifying is the positive one — an untagged card reads as
"not yet verified", and a definitive "lacks bleed" verdict is still
expressible as the tag resolving REJECT through the same vote mechanics.
It's sensitive because that verification is exactly a moderator co-sign: the
crowd votes it via the card modal's tag picker, consensus parks as
pending_approval, and a moderator confirms it in the queue. No report-button
chip (it's a verification workflow, not a complaint) and no search consequence;
drive-level checks can select verified cards via the existing includesTags
filter.
NSFW deliberately reuses the pre-existing cardpicker.constants.NSFW name:
filename-bracket tagging ([NSFW]) and the frontend's default
excludesTags: ["NSFW"] already speak that exact string, and a lowercase twin
would split mature-content state across two names. Once seeded, the real row
replaces the synthetic pseudo-tag in the tag matcher (real rows win the
lowercased key). Seeding upgrades a pre-existing standard row to sensitive but
never clobbers a manually edited display_name. Names are immutable
federation contracts.
AI-Generated (public issue #261) was briefly upgraded to sensitive
(2026-07-21, this same day) from its pre-existing standard row (seeded by
cardpicker.default_tags.DEFAULT_TAGS for a different, orthogonal reason —
filename-bracket tagging, e.g. [Midjourney], applies it directly at
import time, exactly like [NSFW] above, untouched by any of this) —
then reverted back to standard the same day by owner decision, once
cardpicker.local_detect_ai_art (a calculator that scans already-stored OCR
evidence — artist credit line, legal line, collector line — for known
AI-generator marker strings: Midjourney, DALL-E, Stable Diffusion, SDXL,
Gemini, Imagen, Adobe Firefly, Leonardo AI, NightCafe, Bing Image Creator,
"AI art"/"AI generated", plus a 2026-07-22 expansion (ChatGPT, Playground AI,
Niji Journey, and a proactive roster of popular generators including major
Chinese-market products — Doubao, Jimeng, Dreamina, Seedream, Kling, Kolors,
Hunyuan, Tongyi Wanxiang/Wanxiang, ERNIE-ViLG, Wenxin Yige, Hailuo, CogView,
LiblibAI, Meitu, Wujie AI, Qwen, Recraft, Ideogram, Grok Imagine, Flux.1) —
deliberately excluding generator-SITE/tool names
like CardConjurer, which indicate a rendering tool usable with ordinary
human art, not AI provenance — and casts votes for it under its own machine
identity ai-art-detector-v1, VoteSource.OCR) went live and the owner
weighed in on the open question that upgrade had been guessing at (verbatim):
"ordinary human votes is fine for AI I think. or at least not moderator
eyes. they will go contested if there is not an immediate human consensus
that is the system working as intended." So AI-Generated now behaves like
any other standard tag: the shared human-backed gate is unchanged (a lone
machine vote still can never resolve any tag at all, regardless of
moderation_class), but an ordinary confident crowd consensus resolves it
without a moderator co-sign, and a genuinely contested crowd stays
contested — which is the intended outcome, not a gap. A future
privileged-co-sign requirement specifically for this tag remains a
possible follow-up idea, not built now. cardpicker.sensitive_tags. FORMERLY_SENSITIVE_TAG_NAMES (currently just {"AI-Generated"}) lets
seed_sensitive_tags sync this downgrade on any instance that already ran
the brief sensitive-era seed and has the row stuck at sensitive — running
manage.py seed_sensitive_tags again reports it as downgraded, alongside
the usual created/updated counts. Positive-detection only, unchanged:
a missing marker proves nothing, so this calculator never casts a negative
vote, only APPLY.
Two knock-ons worth knowing: the seeded tags become visible/votable in the
card modal's tag grid for everyone (intended — votes accumulate as pending),
and filename-bracket [NSFW] tagging at scan time remains ungated (the gate
governs vote-driven changes; the overlay exclusion means a crowd can't
remove a filename NSFW without a moderator either).
Flag button on the card detail modal → chips: NSFW / Low quality / Wrong card
info / Broken image / Other (+free text ≤280 chars). POST 2/reportCard/
always writes a CardReport audit row (card, anonymous_id, nullable user,
reason, text, created_at — ModelAdmin filterable by reason/date, searchable by
card); the three tag-mapped reasons also cast a positive CardTagVote through
the same write path as 2/submitTagVote/ (extracted helper — the two entry
points cannot drift), in one transaction. Unseeded tag = report still lands,
vote skipped. Broken image / Other are report-row-only. Rate limit:
CARD_REPORT_RATE (default 10/d) per anonymous_id, polite 429 in the UI.
The report panel carries an optional "Also hide this image for me"
checkbox (the panel's own hideForMe state; a report with it checked sends
hide: true in the ReportCardRequest payload). On the server,
views.post_report_card then writes a HiddenCard row (card FK
CASCADE, anonymous_id, created_at; unique per (card, anonymous_id) —
hiddencard_unique_hide) in the same transaction as the CardReport
row, via get_or_create — a HiddenCard never exists without its
accompanying report, and hide: false/absent creates nothing. Hiding a card
for yourself has no moderation meaning: it is purely a per-anonymous_id
view preference, the report's reason/text still lands as a normal audit row.
Read side: the question feed excludes this identity's hidden cards from
every tier and question kind (printing, artist, tag), widened to each
card's md5 identity group — see question_feed._voter_hidden_card_ids
(computed once per feed request and threaded to every branch). Pools skip
them too (question_feed_pools.draw_*'s hidden_card_ids). The exclusion
is per-anonymous_id: one visitor hiding a card never hides it for anyone
else.
Client side: the modal/panel dispatch hideCard(identifier) on a
hide=True submission, which (a) drops the card from the current view
immediately — Modals.tsx gates the card-detail modal on
selectHiddenCardIdentifiersSet — and (b) persists a per-anonymous_id
localStorage mirror under hiddenCardIds:<anonymousId>
(cookies.ts's get/setLocalStorageHiddenCardIds, written through by the
hideCard listener and hydrated once at app start via
getExistingAnonymousId, never minting an identity just by loading). The
localStorage mirror is only session continuity for the current view; the
server-side feed filter is the durable mechanism.
whatsthat.tsx (ModerationTab.tsx) grows a Moderation tab alongside the
ordinary Question Feed tab, rendered only when whoami says moderator
(presentation; every endpoint below 403s non-moderators server-side via
require_moderator). It has two independent sub-tabs — Reports and
Drives — switched with a plain Tab.Container, the same idiom the
pre-redesign printing/artist/tag tab switcher used.
Report review used to be injected into the single-question feed itself as a
moderator-only "tier 3" (between contested and fresh-unresolved), which meant
any pending report displaced a moderator's ordinary tagging work for as long
as it stayed pending. Reverted — cardpicker/question_feed.py's
get_next_question_feed_item never serves a pending_approval pair any
more, for any role; see that module's docstring for the full history. Report
review now lives only in the Moderation tab, so the two are always
switchable, never one hijacking the other.
POST 2/moderationQueue/ serves pending_approval pairs most-reported first
(count of matching-reason CardReports; oldest first report breaks ties;
organically-pending pairs last), each item: card image + tag + report count +
up to three newest free-text excerpts + Approve / Reject / Skip. Approve
and Reject are ordinary 2/submitTagVote/ calls (polarity +1/−1) sent with
credentials, so the vote records the moderator's user and the pair resolves —
or not — through the normal pass. Pending pairs are excluded from the public
tag queue.
A browse-and-manage view over Source rows, for spotting and removing a bad
or spammy drive (or an individual card within an otherwise-fine one) —
unrelated to report review; nothing here requires a CardReport to exist.
-
POST 2/moderationDrives/lists every Source, newest-first (ordered by-pk—Sourcehas no creation timestamp of its own; one existed briefly in 2021 and was removed in favour of per-Carddates, see migration0004_auto_20210214_1126— pk insertion order is a reliable enough proxy for "recently added" without a new migration), each with its card/cardback/token counts. -
POST 2/moderationDriveCards/drills into one drive's individual cards (paginated) so a specific card can be targeted. -
POST 2/moderationRemoveCard/andPOST 2/moderationRemoveDrive/permanently delete a card or an entire drive (cascading onto every card it contributed viaCard.source'son_delete=CASCADE) — irreversible, no soft-delete, confirmed client-side viawindow.confirmsince there's no undo. Both remove from Elasticsearch first (ELASTICSEARCH_DSL_AUTOSYNC = Falsein settings.py means deletes are never automatic — a card-delete reindexes viaCardSearch().update([card], action="delete"), a drive- delete bulk-removes by the indexedsource_pkfield in onedelete_by_queryrather than one ES call per card) before the Postgres delete; Postgres stays authoritative even if the ES side fails (same swallow-and-log rationale asreindex_card_safelyin documents.py).
Cuts the no-match/review-queue human cost by grouping likely-duplicate review-queue cards so a moderator confirms a whole batch as no-match in one action instead of one-at-a-time. Backend only as of this section - no frontend UI yet (a follow-up task consumes the API contract below).
Population clustered: cards currently carrying the slow-path "to-review" routing marker
(CardScanLog(anonymous_id=local_calculate_verdicts.SLOW_PATH_ANONYMOUS_ID, skip_reason="to-review")) that are still printing_tag_status=UNRESOLVED - a card that
resolves (through this feature or independently) drops out of the next computation.
Clustering signals - CONSERVATIVE, exact-match only (cardpicker/review_clusters.py), per
issue #262's own read-only measurement (2026-07-21, 16,928-card review queue): exact
Card.content_phash union exact ImageEvidence.symbol_phash union exact normalized legal-line
text (lowercase, alphanumeric-only, with a minimum-length + alphanumeric-density guardrail so
short OCR noise like "4" never becomes a grouping signal) - plain union-find over these three
EXACT relations, cross-signal-type transitivity intentional (A-B via content_phash, B-C via
legal text still merges A/B/C). The measurement proved Hamming-distance near-duplicate
clustering fails (single-linkage chaining welded a 1,582-card cluster whose true max pairwise
distance was 32) - this is deliberately not implemented, on record so it isn't re-derived.
Only clusters with >= 2 members are ever surfaced (a singleton carries no shared signal and
isn't a useful batch-confirm target).
Cache/compute: computed on demand, cached whole for 5 minutes (Django's default per-process cache - safe only because this app runs a single gunicorn worker, see that module's own docstring for the full reasoning and the 200k-scale caveat). The confirm action below always bypasses this cache for its own membership check and invalidates it afterwards.
API (moderator-gated, same require_moderator/reject_untrusted_origin stack as the
Drives/Reports endpoints above):
-
POST 2/reviewClusters/- paginated list, sorted by size descending, each item: cluster id (the lowest-card-id member's ownCard.identifier), size, which signal(s) bind it (with the shared value/normalized text), and member summaries (identifier/name/small-thumbnail-url only- never pixel data, same "we index, we do not store images" posture as everywhere else).
-
POST 2/reviewClusterDetail/- single-cluster drill-down, served from the same cache. -
POST 2/confirmReviewCluster/- given a cluster id + the EXACT member identifier list the frontend actually showed the moderator (never re-expanded server-side to whatever the cluster currently contains - any submitted identifier that isn't a current member of a fresh recompute rejects the whole request), casts the acting moderator's own human no-match vote per card through the exact same path2/submitPrintingTag/already uses (CardPrintingTag(is_no_match=True, source=VoteSource.USER)+printing_consensus.resolve_and_persist_printing) - no shortcut, normal consensus rules (a single moderator vote alone does not resolve a card any more than a single crowd vote would;PRINTING_TAG_MIN_VOTESstill applies). Idempotent per (moderator, card) via a deterministicanonymous_id(review-cluster-confirm-<user pk>) rather than a client-generated one, so a retried/repeated confirm replaces that moderator's own prior vote instead of duplicating it. Invalidates the list/detail cache on success and logs the action (logger.info- no dedicated audit-log model exists in this codebase yet, same convention every other moderator write action here already follows).
Already true mechanically (default excludesTags: ["NSFW"]); this stage makes
it visible: a Show Mature Content toggle in search filter settings that
adds/removes the NSFW entry in excludesTags — the same state the tag filter
edits, one source of truth. No other consequences this stage (nothing hides
for low-res / incorrect-info yet).
-
SSL cert must exist before nginx is first started.
nginx.confhardcodesssl_certificate /etc/nginx/certs/origin.pem/origin.keyfor the (single)listen 443server block with no HTTP-only fallback — if those files aren't present atdocker/nginx/certs/when thenginxcontainer starts, nginx fails to load its config and the entire site goes down, not just OAuth. On a brand-new server, provision the Cloudflare origin cert and drop it indocker/nginx/certs/(gitignored, never committed — see "Never commit") before the firstdocker compose up/rebuild ofnginx. Steps 1–7 below assume this is already done. - Discord developer portal: create an application, add redirect URI
https://api.proxyprints.ca/accounts/discord/login/callback/. - Env (
docker/.env):DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,SESSION_COOKIE_SAMESITE=None,SESSION_COOKIE_SECURE=True,CORS_ALLOW_CREDENTIALS=True. Optional:VOTE_PRIVILEGED_WEIGHT,CARD_REPORT_RATE,MODERATORS_GROUP_NAME.docker/.envalone does not inject these into the containers — Compose only passes through vars explicitly listed in a service'senvironment:block. All five are already wired into bothdjangoandworkerindocker-compose.prod.ymlas of 2026-07-15 (found missing during the first live setup — a fresh checkout has this for free now, but a new OAuth/session var still needs a matching line added there). -
pip install -r requirements.txt(addsdjango-allauth[socialaccount]), rebuild/restart django + worker containers (and nginx — see the stale-upstream gotcha in Infrastructure). -
manage.py migrate— cardpicker 0057–0059 (all additive) plus allauth's ownaccount/socialaccountmigrations. (If Django's checks ever demand the sites framework, adddjango.contrib.sites+SITE_ID = 1— not needed with allauth 65.x app-in-settings config.) -
manage.py seed_sensitive_tags(idempotent; without it, reports still land but cast no votes and nothing can go pending). - Django admin: create the
Moderatorsgroup (/admin/auth/group/add/). After each moderator's first Discord login, add their auto-created user to the group. - Live OAuth round-trip test: from proxyprints.ca, click "Moderator login
(Discord)" on the What's That Card? page → authorize → land back on the
page → whoami shows
moderator: true→ the Moderation tab appears → cast one Approve on a test pending pair and verify the card's tags + ES search update; reverse the test votes afterwards (the cast-verify-reverse discipline from Printing-Tags). If this 404s, or Discord rejects the redirect, see "nginx routing and proxy headers for/accounts/" below before re-checking Discord portal config — both live bugs on the first production attempt were server-side plumbing, not Discord config.
Two more gaps found live during the first production OAuth attempt
(2026-07-15), both now baked into the checked-in docker/nginx/nginx.conf
and MPCAutofill/MPCAutofill/settings.py so a fresh checkout gets them automatically —
documented here so the reasoning survives if either file is ever touched:
-
Missing
/accounts/proxy route.nginx.confonly hadlocationblocks for/2/,/3/,/admin/,/static/; every allauth URL (/accounts/discord/login/, the OAuth callback, logout) fell through to the static-filelocation /block and 404'd even withDISCORD_AUTH_ENABLED=Trueserver-side. Fixed by an explicitlocation /accounts/ { proxy_pass http://django-api; }block. -
redirect_uribuilt from the wrong host/scheme. nginx'sproxy_passdefaults theHostheader it forwards to$proxy_host(the upstream container's own name,django-api) rather than the original client'sHost, and Django never learns the original request was HTTPS without being told. Together this made django-allauth constructhttp://django-api/accounts/discord/login/callback/as the OAuthredirect_uri— an internal-only, plain-HTTP URL Discord's registered callback can never match, so Discord silently rejected the login. Fixed withproxy_set_header Host $host;andproxy_set_header X-Forwarded-Proto $scheme;at the nginx server-block level (inherited by everylocationbelow it), paired withSECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")insettings.py— without the Django-side setting,request.is_secure()ignores the forwarded header and still readsFalse. Verified by manually walking the flow withcurl(GET the login page for a CSRF token, POST it, inspect the 302Locationheader) rather than a full browser round-trip.
- Backend:
cardpicker/vote_consensus.py(the gate),cardpicker/tag_consensus.py,cardpicker/moderation.py,cardpicker/security.py,cardpicker/sensitive_tags.py(+ management command),cardpicker/models.py(user FK,TagModerationClass,CardReport,TagVoteStatus.PENDING_APPROVAL),cardpicker/question_feed.py(report review deliberately absent - see "Moderation tab" above),cardpicker/views.py(whoami / reportCard / moderationQueue / moderationDrives / moderationDriveCards / moderationRemoveCard / moderationRemoveDrive / reviewClusters / reviewClusterDetail / confirmReviewCluster),cardpicker/review_clusters.py(issue #262's clustering + cache),accounts/adapter.py,MPCAutofill/MPCAutofill/settings.py. - Frontend:
features/reporting/ReportCardPanel.tsx,features/moderation/AuthWidget.tsx(Discord-branded login button) +ModerationTab.tsx(Reports/Drives sub-tab switcher) +ReportsPanel.tsx-
DrivesPanel.tsx,features/filters/MatureContentFilter.tsx,pages/whatsthat.tsx(Question Feed/Moderation tab switcher, moderator- only),store/api.ts(whoami query + credentialed fetches).
-
- Tests:
cardpicker/tests/test_moderation_gate.py,test_moderation_views.py,test_sensitive_tags.py,test_question_feed.py(asserts pending-approval pairs never surface in the ordinary feed);test_review_clusters.py(clustering unit tests),test_review_cluster_views.py(API auth/pagination/batch-confirm tests);frontend/src/features/reporting/ReportCardPanel.test.tsx;frontend/tests/{ReportCard,ModerationQueue,ModerationTab,MatureContentToggle}.spec.ts.
- Discord guild-role sync for a federation-wide moderator roster (see "Who is a moderator").
- Federation export/import of moderation verdicts is v1.1
- Review-cluster frontend UI (issue #262) - the backend/API above is code-only as of this section; the Moderation tab has no cluster-browsing/batch-confirm surface yet. (Federation-v1) — explicitly out of scope here.
- No consequences yet for resolved
low-res/incorrect-info. - The rate limiter shares the existing single-gunicorn-worker in-process-cache
caveat (documented as a code comment on
post_submit_printing_tag,cardpicker/views.py:860-863— not currently written up in any doc).
Verified, not a gap: live Discord OAuth working end-to-end in production
2026-07-15 (server-side plumbing via curl simulation, then a real
moderator's browser round-trip) — see "nginx routing and proxy headers for
/accounts/" above.
Understanding the system
- Overview
- Documentation-Process
- Theory
- Identification-Pipeline
- Pipeline-Fidelity-Gate
- Federation-v1
- Vote-System
- Readiness-Audit
- License-Provenance
- Upstreaming-Conventions
- Drift-Log
- Upstream-Wiki-Drift
- Printing-Tags
- Catalog-Completion-Plan
- Moderation
- Card-DOM-API
- PDF-Generator
- Print-Export-Page
- Google-Drive-Connect
- Grid-Selector
- Image-CDN
- Local-File-Source
Using it
Operating it
Folded into other pages