Skip to main content

Admin

All admin endpoints are under /api/v1/admin. They require a valid Supabase access token whose user UUID appears in the API Worker's comma-separated ADMIN_USER_IDS variable.

Authorization: Bearer <access_token>

Missing or invalid tokens return 401. Authenticated users outside the allowlist receive 403 ADMIN_REQUIRED. Service credentials and Cloudflare bindings never leave the API Worker.

Persistence model​

Admin mutations write the live oracle, printing, or set row and append an audit entry in the same database transaction. Patched fields are added to that row's locked_fields, which prevents ingest from overwriting the decision.

Manual records are ordinary rows with source: "manual", and ingest does not prune them. Deletes set deleted_at; restore endpoints clear it. There is no separate override, manual-record, or deletion overlay.

The two card levels remain distinct:

  • Oracle: rules identity, including name, type, stats, rules text, tags, domains, keywords, equipment data, token status, and relationships.
  • Printing: one physical edition, including set, collector number, rarity, art, artist, flavour, finishes, marketplace data, and variant flags.

A printing delta is different from a lock. It records a genuine rules difference on one printing, such as adding or removing a tag. It does not mean that an admin merely corrected a printing-level field.

Endpoint summary​

Paths in the tables below are relative to /api/v1/admin.

Audit and review​

MethodPathPurpose
GET/statsDashboard totals: sets, oracles, printings, pending review
GET/audit-logList mutations, newest first
GET/reconciliationList ingest review entries
POST/reconciliation/:id/confirmApply a supported proposal and close it
POST/reconciliation/:id/dismissClose an entry without changing card data

Oracles​

MethodPathPurpose
POST/oraclesCreate a manual rules object
PATCH/oracles/:idPatch rules fields and lock the submitted keys
DELETE/oracles/:idSoft-delete an oracle and all its printings
POST/oracles/:id/restoreRestore an oracle and its printings
GET/oracles/:id/relationshipsRead outgoing and incoming oracle edges
PUT/oracles/:id/relationshipsReplace all outgoing oracle edges

Printings​

MethodPathPurpose
GET/printingsList the catalogue, including rows public search cannot see
POST/printingsAdd a physical printing to an oracle
PATCH/printings/:idPatch printed fields; set_code moves sets
DELETE/printings/:idSoft-delete one printing
POST/printings/:id/restoreRestore one printing
POST/printings/:id/regenerate-slugDeliberately repin its public slug
GET/printings/:id/deltasRead its admin-authored rules delta
PUT/printings/:id/deltasSet, replace, or clear that delta
POST/printings/:id/imageStore an image source and queue variants
GET/printings/:id/legalitiesRead resolved format statuses and scopes
PUT/printings/:id/legalitiesSet or clear a printing- or oracle-level status
GET/printings/:id/rulingsRead every ruling that reaches the printing

Catalogue administration​

MethodPathPurpose
GET/formatsList active and retired formats
POST/formatsCreate a format
PUT/formats/orderReplace format order
PATCH/formats/:codePatch name, order, or active state
DELETE/formats/:codeDelete a format and its legality rows
PUT/formats/:code/zone-rules/:zoneSet what the format demands of one deck zone
DELETE/formats/:code/zone-rules/:zoneLeave that zone unconstrained
PUT/formats/:code/severities/:legality_statusOverride how loudly a status reads
GET/rulingsList rulings and targets
POST/rulings/previewEvaluate a query target without storing it
POST/rulingsCreate a ruling and its targets
PATCH/rulings/:rulingIdPatch a ruling or replace its targets
DELETE/rulings/:rulingIdDelete a ruling and all targets
POST/setsCreate a manual set
PATCH/sets/:setCodePatch and lock set fields
DELETE/sets/:setCodeSoft-delete an empty set

Audit log​

GET /audit-log accepts limit, offset, action, target_type, target_id, and actor_id. The default page size is 50 and the maximum is 200. target_type values reflect the real row being changed: oracle, printing, set, format, ruling, or reconciliation.

{
"entries": [
{
"id": 42,
"actor_id": "00000000-0000-0000-0000-0000000000aa",
"action": "printing.patch",
"target_type": "printing",
"target_id": "67f4064886be8495f7165dd7",
"detail": { "rarity": "Showcase" },
"created_at": "2026-08-01T12:00:00Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}

Entries are append-only and ordered by created_at, then id, descending.

Oracles​

Create an oracle with a definition containing name and any rules fields:

{
"definition": {
"name": "Sun Disc",
"card_type": "Gear",
"energy": 2,
"might_bonus": 0,
"text_rich": "[Equip] ...",
"tags": ["Relic"],
"domains": ["Order"]
}
}

Editable oracle fields are name, card_type, supertype, is_token, energy, might, power, might_bonus, equipment_text, text_rich, text_plain, tags, domains, and meta_flags. A patch is wrapped in { "patch": { ... } }. Omitted keys stay unchanged and explicit null clears nullable values. Name changes also update the normalized lookup key, but never move the pinned public slug.

Oracle deletion hides the oracle and all its printings. Restoration clears the soft-delete state and rebuilds the resolved projection.

Relationships​

Relationships are directed oracle-to-oracle edges stored once. The only kinds are:

KindDirection
makes_tokenProducer oracle → token oracle
characterLegend oracle → champion oracle
signatureCharacter oracle → signature-card oracle

used_by is the reverse view of makes_token; it is not a fourth stored kind. There are no printing-scoped relationship exceptions.

GET /oracles/:id/relationships returns { oracle_id, outgoing, incoming }. PUT replaces the complete outgoing list:

{
"entries": [
{
"kind": "makes_token",
"to_oracle_id": "3d8f2da9-9d2b-4a2c-a34d-6f08b54c9571"
}
]
}

Self-edges and duplicate kind/target pairs are rejected.

Printings​

Listing​

GET /printings is the admin catalogue list, and exists because public search cannot answer the questions an admin opens the list to ask. The search grammar is deliberately a language about cards; every filter here is a fact about the catalogue:

stateSelects
live (default)Everything not soft-deleted
deletedSoft-deleted rows — the only way to find one to restore
manualsource = 'manual', the rows ingest's prune skips
lockedRows carrying at least one admin-locked column
deltaRows carrying a printing delta, from either layer
no_imageRows with no hosted R2 variant set

Also accepts q (card-name substring), set (set code, upper-cased), id (exact printing id), limit (max 200) and offset.

deleted is the reason this reads the printings table rather than the resolved_printings projection: the projection excludes soft-deleted rows, which is exactly what makes deleted_at a real delete for every other reader, and leaves this endpoint as the only way back.

Each entry carries locked_fields and oracle_locked_fields — locks are per row, so the two levels are reported separately — plus delta_source (ingest, admin or null) and has_hosted_image.

Creating and editing​

Create a printing with a caller-supplied text ID, an existing oracle UUID, a set code, and physical-card fields:

{
"id": "67f4064886be8495f7165dd7",
"oracle_id": "3d8f2da9-9d2b-4a2c-a34d-6f08b54c9571",
"set_code": "OGN",
"definition": {
"collector_number": "042a",
"rarity": "Showcase",
"artist": "Jane Doe",
"is_alternate_art": true,
"finishes": ["Normal", "Foil"]
}
}

Editable printing fields are set_code, collector_number, released_at, rarity, flavour_text, finishes, artist, is_signature, is_alternate_art, is_overnumbered, is_special_collection, tcgplayer_id, tcgplayer_url, and cardmarket_url. set_code on the normal patch route replaces the old move endpoint.

Printing slugs are generated on creation and otherwise pinned. Regeneration is an explicit link-breaking operation. Deletion and restoration affect only the printing in the path.

Printing deltas​

GET /printings/:id/deltas returns delta: null when the printing fully inherits its oracle. Only an admin-authored delta is exposed by this editor.

PUT accepts { "delta": { ... } }. Array fields use paired additions and removals: tags, domains, keywords, and meta_flags. Scalar fields use *_override; cleared_fields explicitly blanks a scalar because null in an override column means inherit. A null, omitted, or empty delta clears the row.

{
"delta": {
"tags_added": ["Elite"],
"tags_removed": ["Sentinel"],
"energy_override": 4,
"cleared_fields": ["power"]
}
}

Images​

Image uploads are multipart requests with a required file and optional accessibility_text. JPEG, PNG, WebP, AVIF, and GIF files up to 20 MB are accepted after content sniffing. The API stores a content-addressed source in R2, locks that source on the printing, and queues variant generation. A 202 response reports queued; when false, the durable source remains eligible for the next ingest scan.

Legalities​

Legality precedence is printing row → oracle row → legal by default. GET /printings/:id/legalities returns one entry per active format with status, scope (printing, oracle, or default) and note — the admin-authored explanation stored on whichever row decided the status, and null at default scope.

PUT /printings/:id/legalities accepts:

{
"format_code": "standard",
"status": "restricted",
"note": "One copy as of the 2026-07 update",
"apply_to_all_printings": true
}

Statuses are legal, restricted, not_legal, banned, or default. default deletes the stored row, and the note goes with it: a note explains a status and has nowhere to live without one. Without apply_to_all_printings, the route writes a printing exception. With it, the route writes the owning oracle's status and clears all printing exceptions for that oracle and format. At oracle scope, legal is represented by no row; at printing scope it can be an explicit exception to an oracle ban.

Rulings​

GET /printings/:id/rulings is read-only. It returns rulings that reach the printing through a printing target, its oracle, or a materialized query rule. Shared rulings are edited centrally because one ruling can affect many cards.

Central ruling targets use real IDs:

{ "kind": "oracle", "oracle_id": "3d8f2da9-9d2b-4a2c-a34d-6f08b54c9571" }
{ "kind": "printing", "printing_id": "67f4064886be8495f7165dd7" }
{ "kind": "query", "query": "t:unit kw:deathknell" }

Create a ruling with { type, text, dated?, source?, targets }. type is ruling or note. On patch, targets replaces the whole target list; omitting it preserves the current targets. At least one target is required.

Query targets use the same parser and SQL evaluator as card search. Invalid or empty queries are rejected before any write. POST /rulings/preview evaluates { query, limit? } without storing it. Saved query targets are materialized when the ruling changes, after ingest, and inside card mutations that can change whether a printing matches.

A returned target carries label, the resolved card name, so a stored target reads as a card rather than as the id it is keyed on. It also carries deleted: a soft-deleted oracle or printing keeps its target row — the cascade only fires on a hard delete — while dropping out of both card-page read paths, so the ruling stops reaching anything with nothing else to say why.

A ruling always has at least one target at write time, but ON DELETE CASCADE can empty one afterwards. GET /rulings uses a left join and still returns those; GET /printings/:id/rulings and the public read path both inner-join targets and cannot. The central list is the only place such a ruling surfaces.

Formats​

Format codes are normalized to lowercase and are immutable after creation. Create with { code, name, sort_order?, active? }; patch with { "patch": { "name"?, "sort_order"?, "active"? } }.

PUT /formats/order takes the complete ordered code list as { "codes": [] }. Unknown codes are rejected. Deleting a format cascades its oracle and printing legality rows and reports their counts. Retiring with active: false preserves those rows while removing the format from public active-format responses.

Deck construction rules​

GET /formats also returns each format's zone_rules and severity_overrides.

PUT /formats/:code/zone-rules/:zone upserts one zone's constraints with { "min_count"?, "max_count"?, "copy_limit"? }. Zones are legend, main, sideboard, runes, battlefields and considering. Null (or an omitted bound) means unconstrained, not zero — a format with no rules at all enforces nothing. copy_limit applies across the zone's counting group, so main and sideboard share one limit. DELETE on the same path returns the zone to unconstrained and is idempotent, reporting deleted: false when there was no rule.

PUT /formats/:code/severities/:legality_status stores this format's departure from the default severity mapping in @riftseer/types (legal → none, restricted → warning, not_legal → error, banned → error). severity is none, warning, error, or default; default deletes the override and falls back, while none is a stored decision that the status should say nothing.

Sets​

Create a manual set with { set_code, definition }. Patch with { "patch": { ... } }; submitted fields are locked against ingest. Set codes are normalized to uppercase. A set can only be soft-deleted after every printing has been moved or deleted.

Reconciliation queue​

GET /reconciliation accepts limit, offset, status, kind, and source. It defaults to pending entries. Kinds are:

KindMeaning
unmatched_productA TCGPlayer product is not linked to a printing
field_diffTCGPlayer or the gallery disagrees with a stored field
missing_printingThe gallery reports a physical printing not present locally
unmatched_oracleA new printing cannot be assigned safely to a rules object

Prices are never review proposals. Confirmable printing fields are collector number, release date, and rarity. Confirmable oracle fields are card type, energy, might, and power. Rules-text disagreements require a manual edit and dismissal because the observed markup is not the stored representation.

POST /reconciliation/:id/confirm accepts optional printing_id, oracle_id, and note, using the proposed IDs when omitted. Confirming a supported field applies the normal patch path, so the field becomes locked. Confirming an unmatched product locks its TCGPlayer ID and URL on the printing. Missing-row entries carry no patch: create the oracle or printing first, then confirm to record the reviewed gap. dismiss accepts an optional note and never changes card data.

Only pending entries can be resolved. Confirmed and dismissed fingerprints stay closed; a genuinely changed upstream observation receives a new fingerprint.

Errors​

Errors use a stable envelope:

{ "error": "Human-readable message", "code": "MACHINE_CODE" }

Expected statuses are 400, 401, 403, 404, 409, 500, and 503. Database messages and stack traces are not returned.