Dicey Table

Writing a Plugin

A plugin is a public GitHub repository with diceytable.plugin.json at its root, registered against an immutable commit sha. This page walks the manifest field by field and explains the runtime rules an author cannot see from the schema alone: the quota you actually get, the breaker you share, and the four words a caller will ever hear when something fails.

Read Plugins first if you have not — the two-half split it describes is the reason most of the fields below are shaped the way they are.

The manifest at a glance#

{
  "schemaVersion": "1.0",
  "id": "org.example.cards",
  "name": "Example Card Data",
  "version": "1.0.0",
  "breakingVersion": 1,
  "summary": "Card search and lookup for Example TCG.",
  "compatibility": { "engine": "^0.1.0" },
  "capabilities": { "version": "1", "allowed": ["network"] },
  "origins": ["api.example.com"],
  "endpoints": [
    {
      "name": "searchCards",
      "origin": "api.example.com",
      "method": "GET",
      "path": "/v1/cards/search",
      "query": { "format": "json" },
      "params": [
        { "name": "q", "location": "query", "format": "text", "maxLength": 120, "required": true },
        { "name": "page", "location": "query", "format": "token", "required": false }
      ],
      "auth": { "kind": "bearer" },
      "returns": "cards"
    }
  ],
  "cardMapping": {
    "root": "data",
    "nextPagePath": "next_page",
    "identityField": "key",
    "fields": {
      "key": { "path": "id", "required": true },
      "title": { "path": "name", "transforms": ["trim"], "required": true },
      "art": { "path": "image_uris.normal", "fallbackPaths": ["card_faces[0].image_uris.normal"] }
    }
  },
  "exposedApi": [
    {
      "name": "searchCards",
      "summary": "Search the provider's card database by name text.",
      "endpoint": "searchCards",
      "params": { "type": "object", "fields": { "q": { "schema": { "type": "string", "maxLength": 120 } } } },
      "returns": { "type": "object", "fields": { "cards": { "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 250 } } } }
    }
  ],
  "attribution": "Card data © Example Games, used under the Example API terms.",
  "termsUrl": "https://example.com/api/terms",
  "rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 2 },
  "license": "MIT",
  "tags": ["cards", "tcg"]
}

The manifest is strictly parsed. An unrecognised key is a rejection, not something ignored. That is deliberate: it is what makes "no credential in a manifest" mechanical rather than advisory, because "apiKey": "…" does not parse at all.

Origins: hostnames, and nothing that is not a hostname#

origins is the complete list of hostnames the plugin may reach — at most eight, and every endpoint's origin must be one of them. They are bare lowercase hostnames:

"origins": ["api.example.com", "images.example.com"]

Every richer spelling is rejected at publish time, and each rejection closes a specific hole:

Rejected Why
https://api.example.com A scheme invites a downgrade, and //evil.example is a protocol-relative form.
*.example.com Makes the allowlist depend on whoever controls a subdomain.
api.example.com:8443 A port reaches an internal service on a host that is otherwise legitimate.
[email protected] Puts an attacker-chosen authority in front of the real one.
127.0.0.1, [::1], 169.254.169.254 An address literal is the target of the attack this list exists to stop.
localhost Not fully qualified — an origin needs at least one dot.

The schema is the first of two gates. The fetch layer resolves the hostname, pins the address it resolved to, and refuses private and link-local ranges after resolution, so a hostname that resolves inward is refused at request time even though it parsed.

Endpoints: you name one, the platform builds the request#

There is no url field and there will never be one. The platform composes https://{origin}{path} from values it validated at publish time, substitutes typed parameters, and refuses everything else. A plugin cannot express a destination, which is why the origin allowlist holds regardless of what any plugin does.

At most sixteen endpoints. Each carries:

Field Rule
name A letter followed by letters, digits or underscores, up to 64 characters. Unique within the manifest.
origin One of the declared origins.
method GET or POST, defaulting to GET. POST exists only for providers whose bulk lookup demands it — there is no PUT, DELETE or PATCH, because a card-data plugin reads.
path A path template (below).
query Fixed query values the platform always sends, e.g. { "format": "json" }. At most sixteen. Never URL-shaped.
params At most sixteen typed parameters (below).
auth Where the platform puts your credential. See Credentials.
returns cards, raw or catalogue — see below. Nothing passes through unmapped.

Which returns kind to use#

Kind Fetched Bounded by Use it when
cards Per user, in their critical path 2 MiB Your provider has a search endpoint that returns a page of cards.
raw Per mod call 2 MiB The response is not cards — a rulings lookup, a decklist. Validated against the exposed function's declared return schema.
catalogue By us, on a schedule 16 MiB Your provider publishes its whole dataset in one document and offers no search.

catalogue exists because a large class of providers — community and fan card databases especially — have no search API at all: they publish one file containing everything and expect the client to filter it. That shape was previously unrepresentable, and the 2 MiB request ceiling refused it anyway.

A catalogue endpoint is fetched with no user waiting, normalized once through your cardMapping, and held for the deck builder to search locally. So:

  • it must be GET, and it may not have required parameters — nothing is present at fetch time to supply one. Optional parameters carry their declared defaults;
  • it is refreshed periodically rather than per request, which is cheaper for your provider than per-user search: one read per interval shared by everyone;
  • if a refresh fails, the previous ingest keeps being served, marked stale, for up to a week. An empty response counts as a failure — a shipped card game is never genuinely cardless;
  • it cannot be selected by a caller. The /cards route resolves only cards endpoints, which is what keeps the larger read ceiling off any user-triggered path.

Path templates#

A path is a leading /, literal segments, and {param} placeholders:

/v1/cards/search
/v1/cards/{cardId}
/v1/sets/{setCode}/cards

Rejected outright: a scheme, a leading //, . or .. segments, a backslash, a percent-escape, an @, a ? or #, whitespace and control characters. What survives cannot name a host.

Every {placeholder} must have a matching parameter that declares location: "path" and required: true. A missing path segment changes which resource is addressed, so an optional one is a publish error rather than a runtime surprise.

Parameter formats#

There is deliberately no author-supplied regex. A regex evaluated on our servers against attacker-influenced input is a denial-of-service surface, and it cannot be checked statically for "can this match a slash?" — which is the only question a path parameter has to answer. Closed formats answer it by construction.

Format Accepts Legal in a path?
token [A-Za-z0-9._-], 1–120 characters — the shape of a provider's opaque id yes
uuid Canonical 8-4-4-4-12 yes
integer Digits, optional leading -, up to 15 digits yes
enum One of a declared options list, at most 64 entries yes
boolean true or false no
text Free text up to maxLength (200 maximum) no — query only

A query value is percent-encoded and cannot change the host or the path, so free text is fine there; a search box is free text. A path value becomes part of the URL, where a / or a .. re-points the request.

options is required for enum and forbidden otherwise; maxLength applies only to text; a default must itself satisfy the format it sits on.

The card mapping#

Decision D-S1: the data half is a declarative mapping, not author JavaScript running on our servers. There is no normalize.js, no expression language, no callbacks, and nothing that could grow into one. A reviewer can read a mapping end to end and know exactly which upstream keys it touches.

cardMapping is required when any endpoint declares returns: "cards", and forbidden when none does.

"cardMapping": {
  "root": "data",
  "nextPagePath": "next_page",
  "totalPath": "total_cards",
  "identityField": "key",
  "fields": {
    "key":   { "path": "id", "required": true },
    "title": { "path": "name", "transforms": ["trim"], "required": true },
    "art":   { "path": "image_uris.normal", "fallbackPaths": ["card_faces[0].image_uris.normal"] },
    "cost":  { "path": "mana_cost", "default": "" },
    "types": { "path": "type_line", "transforms": ["join"], "joinSeparator": " / " }
  }
}
  • root addresses the array of card records inside the response — "data" for a wrapped response, "" when the response is the array.
  • fields maps deck-schema field keys to one read each. Field keys are lowercase snake_case, at most 40 characters, because they become keys of the normalized card's data bag and are validated again when that bag is read.
  • identityField names the field carrying the card's stable id. It must be one of fields and must be required — a saved deck stores that value, and a card with no id cannot be re-resolved. Changing what it means is the definition of a breaking change; see breakingVersion and migration.

Read paths are a JSONPath-ish subset, and the "-ish" is the point. Dotted keys and numeric indices, at most eight segments deep. No $, no *, no .. descent, no filters, no function calls, no slices — those are the features that turn a path language into an expression language. __proto__, constructor and prototype are rejected as key segments: each is a syntactically valid dotted path that reaches the prototype chain, and no provider's response legitimately contains one.

Each field is resolved as path, then each of fallbackPaths in order (at most four), then default. Real providers need the fallbacks — a double-faced card's art lives at image_uris.normal on one shape and card_faces[0].image_uris.normal on another. Transforms then run left to right, at most four of them, from a closed list:

trim · lowercase · uppercase · number · boolean · string · join · first

joinSeparator applies only to join. A field that is required may not also declare a default — a record that yields nothing for a required field is dropped rather than emitted with a placeholder.

A single page maps at most 2000 records; anything beyond that is dropped and counted in a host-side diagnostic you can read and the caller cannot.

artUrl — when your provider returns the pieces of an image URL#

Many providers never return an image URL. They return its parts:

{ "name": "Anakin Skywalker (T)", "set": "CWSO", "imageFrag": "SO001_Anakin_Skywalker_T_v3" }

The transform list cannot join those into a URL, on purpose — a template or format string is the author-supplied mini-language the closed vocabulary exists to refuse. Instead, declare the URL the same way you declare a request: an origin and a path template that the platform composes.

"cardMapping": {
  "identityField": "card_id",
  "fields": {
    "card_id": { "path": "imageFrag", "required": true },
    "set":     { "path": "set" },
    "art":     { "path": "image_url" }
  },
  "artUrl": {
    "field": "art",
    "origin": "images.example.com",
    "path": "/sets/{set}/{frag}.jpg",
    "params": { "set": "set", "frag": "card_id" }
  }
}
Key Rule
field Which mapped field receives the composed URL. Must be one of fields. Whatever that field mapped to is overwritten — declaring artUrl says the composed value is the real one.
origin A bare hostname that must be one of your declared origins.
path Literal segments and {placeholder}s. Unlike an endpoint path, a placeholder may sit inside a segment, so {frag}.jpg is fine.
params {placeholder} → mapped field key. Every placeholder needs one, and every entry needs a placeholder.

Values are substituted after their transforms have run, and each is percent-encoded, so a value carrying /, ?, # or .. becomes inert text rather than re-pointing the URL. If any placeholder has no usable value the card gets no art rather than a URL with a literal {set} in it.

Your art host must be a declared origin. Before artUrl, a card's art was whatever string a field mapped to, handed to the viewer's browser unexamined — the origin allowlist governed only the calls we make. A card source could therefore point every viewer at any host. Composing art brings the host a viewer is sent to inside the same reviewed list.

Note this is a separate question from whether the browser will load it: the app's Content-Security-Policy pins image and fetch hosts to a fixed list, so an art host that is not on it renders a placeholder tile in the deck builder and a blank card on the table.

Which art hosts actually load

Two hosts:

Host Use it for
raw.githubusercontent.com Art committed to a public GitHub repository — the usual answer.
api.diceytable.com Art the platform itself serves.
cdn.diceytable.com The same GitHub repository, through DiceyTable's asset CDN. Requires an assetRepos declaration — see below.

Registering a plugin whose artUrl.origin is anything else succeeds, but returns an art-host-not-loadable warning naming the host. It is a warning and not a rejection on purpose: the policy is deployment configuration rather than part of the plugin contract, so a plugin that registered cleanly today must not stop parsing the day the policy is edited, and a plugin whose host we are about to add should not be stranded.

The list is the intersection of the img-src and connect-src directives, because card art is read twice by two different mechanisms — the deck builder puts the URL in an <img src>, and the table runtime fetches it to compose a sprite sheet. A host present in only one of them produces the worst possible symptom: art that renders perfectly while browsing a deck, and blank cards on the table, with a console message nowhere near the deck code.

Serving art through the asset CDN

Art on raw.githubusercontent.com reaches players over a distant origin. Pointing it at cdn.diceytable.com instead puts it on the same edge as the rest of the app — but the edge serves only repositories the platform can trace to a pack, so you must name the one you are reading:

"origins": ["api.example.com", "cdn.diceytable.com"],
"assetRepos": [{ "owner": "SWTCG", "repo": "SWTCG-LACKEY", "ref": "refs/heads/release" }],
"cardMapping": {
  "artUrl": {
    "field": "art",
    "origin": "cdn.diceytable.com",
    "path": "/SWTCG/SWTCG-LACKEY/refs/heads/release/sets/{set}/{frag}.jpg",
    "params": { "set": "set", "frag": "card_id" }
  }
}

Two rules, both errors at registration rather than warnings, because both are decidable from the manifest alone and getting either wrong means every card 404s with nothing to fall back to:

  • the first three segments of the path must be literal — a {placeholder} in owner, repo or ref means the manifest does not actually name the repository it reads;
  • that repository must appear in assetRepos, at the same ref.

At most four repositories, and they are shown on the pack pages of games that use your plugin. Your plugin's own repository is never eligible: nothing in it is fetched by a browser. A branch ref is allowed but warns — see Asset Delivery and the CDN for the caching trade-off and the full rule set.

Settings: letting a mod configure your plugin#

A plugin often needs one or two decisions from the pack using it — which sets to include, whether to pull promos, how many results to ask for. settings is how you ask, and it is the same trade the rest of this contract makes: you declare the shape, the platform renders the form.

"settings": [
  { "key": "set_filter", "label": "Sets to include", "control": "select",
    "options": [
      { "value": "all",  "label": "All sets" },
      { "value": "core", "label": "Core only" }
    ],
    "default": "all",
    "help": "Narrows every card search this plugin performs." },
  { "key": "include_promos", "label": "Include promos", "control": "toggle", "default": false }
]

A mod author opens diceytable.mod.json in the editor, picks your plugin, and fills in exactly that form. Their answers land on their manifest under pluginSettings, keyed by your plugin id — so the question and the answer are both public, commit-pinned documents a reviewer can read side by side.

Key Rule
key Lowercase snake_case, unique within your plugin. This is what the answer is keyed by, so renaming one strands the old value.
label What the author sees beside the control.
control One of text, number, toggle, select. Closed set — see below.
help One line under the control. Rendered as text, never as markup.
default Used when the author does not answer. Must match the control's type, and for a select must be one of its options.
options select only, and required for it. Literal value/label pairs — an option list is data, never a fetch.
min / max number only.
maxLength text only.

At most 12 settings, and 32 options per select. A modifier on the wrong control is a publish-time error rather than a value that is quietly ignored — min on a toggle means you believe something is happening that is not.

The control list is closed, and that is the point. You declare which form element, never how it looks, how wide it is, where it sits, or what markup it is — every one of these renders as one of the platform's own components. That is the same relationship ui has with the deck builder's search controls, and it is the reason a plugin is allowed to extend the editor at all: there is no path by which your document becomes your UI inside somebody else's editor.

Answering is always optional. Every setting falls back to its default, so adding a setting never breaks a mod that already uses your plugin. Removing or renaming one does strand the old answer — the editor shows it as a stale key with a Remove button rather than discarding it — so treat it the way you would any other breaking change and bump breakingVersion.

Deck import: letting a plugin supply a deck, not just cards#

A plugin could always supply card data. Until deckImport it could not supply a deck. A deck database's whole reason to exist is that people build decks there and share links, and the only route those decklists had into DiceyTable was api.callPlugin from a mod's table script — so an imported deck landed on the table as a pile of cards rather than as a saved deck. It could not be re-opened, edited or played again without re-importing; every mod that wanted import had to ship its own text box; and because the call came from a script it needed the plugin-call capability, which also permits every other function that mod names.

deckImport is an optional block that closes that gap without introducing a new mechanism. A decklist response is just JSON with an array in it, which is exactly what the card mapping already describes — so this block reuses the same closed path grammar and the same transform vocabulary. Nothing in it is an expression language, and nothing in it can name a destination.

"deckImport": {
  "endpoint": "deckById",
  "param": "deckId",
  "label": "Deck id or swtcg-deckdb.com link",
  "matchField": "name",
  "namePath": "name",
  "root": "cards",
  "entry": {
    "count": { "path": "count", "transforms": ["number"], "default": 1, "required": false },
    "match": { "path": "name", "transforms": ["trim"], "required": true }
  },
  "partitions": [{ "root": "supply", "partitionId": "supply" }]
}
Key Rule
endpoint A declared endpoint, which must be returns: "raw".
param The declared parameter the person's input fills. Must be required, and must be that endpoint's only required parameter.
label Your label for the input box, 1–60 characters. Sanitized on render; the platform's own sentence leads.
matchField The catalogue field an imported entry matches on.
setField Optional second catalogue key, for a catalogue whose matchField values repeat across sets. Only meaningful with entry.set.
namePath Optional path to the deck's own name in the response, so an imported deck arrives named. Up to 120 characters are relayed.
root Path to the primary entry array — "" when the response is the array.
entry.match Where one entry's join value is read. Must be required.
entry.count Copies. Absent, unreadable or below 1 all mean one copy; capped at 1000.
entry.set Where the entry's set/edition is read. Only with setField, and vice versa.
partitions Up to 8 further arrays, each landing in a named deck partition — a sideboard, a supply pile.

At most 2000 entries are taken across all of a response's arrays: that is the ceiling a saved decklist has, so mapping more would be work spent producing rows the save path must reject.

Every cross-check, and the failure each one prevents#

All of these run at publish, so an authoring mistake is a parse error naming the exact path rather than an import button that answers nothing at a player's screen.

  • endpoint must be declared, and must be returns: "raw". Same rule and same reason as exposedApi: a cards endpoint's response has already been consumed by the card mapping and comes back as a normalized card page, not as the provider's decklist document. There is nothing left to map a decklist out of.
  • param must be a declared parameter of that endpoint. Otherwise the import composes a request the endpoint does not describe.
  • param must be required. An optional one means an import that supplied nothing would still fetch something — a provider's default deck rather than the one the person asked for.
  • param must be the endpoint's only required parameter. The platform sends exactly one value, because the person typed one thing into one box. A second required parameter would make every import fail at compose time, far from the manifest that caused it.
  • entry.match must be required. An entry with nothing to match on can be neither resolved nor usefully reported, so it could only be dropped in silence.
  • setField and entry.set arrive together or not at all. One without the other is either a catalogue key with nothing to compare against or a value read for no reason.
  • No two entry arrays may read the same root. They would import the same entries twice.
  • partitionIds must be unique within the block.
  • When the plugin itself declares a card mapping, matchField/setField must be mapped card fields of it — checked against every mapping the manifest carries. If this plugin supplies the cards then it is this plugin's catalogue that gets loaded, so a field it never maps could never match one of its own cards.

Two rules that were considered and deliberately are not enforced#

A deckImport block does not require the plugin to declare a card source. The first real provider was exactly the case such a rule would have refused: swtcg-deckdb.com shipped as a deck database supplying no card data at all, while the game using it shipped a static catalogue. (It supplies cards today, through a catalogue endpoint — but only because the platform gained one; nothing about its import block changed when it did, which is the point.) The join runs in the browser and works identically for a static card source and a plugin one, so demanding a card source here would refuse the only plugin the feature exists for.

The consequence is yours to get right: matchField names a field of whatever catalogue the game loaded, which is not necessarily yours. It is only cross-checkable at publish when the plugin itself supplies the cards.

partitionId is not checked against a game's deck schema at publish. A plugin manifest cannot see the schema of every game that will use it, and the same provider legitimately serves games that partition differently — so partitionId is checked for shape here and resolved at import time. An id the game does not declare puts those entries in the game's default partition and reports the mismatch; it never silently retargets them.

How the join works, and why it is in the browser#

The import route returns flat entries — {count, match, set?, partitionId?} — and no card ids. It cannot produce them: the server does not know which catalogue the deck builder loaded. The join therefore runs in the browser against the catalogue already on screen, which costs no extra request and behaves identically whether that catalogue came from a mod's static card list or from a plugin.

Matching is case- and whitespace-insensitive, and nothing more: the key is trim() + toLowerCase() on both sides. "Hidden Beks (A)" and "hidden beks (a)" are the same card; "Hiden Beks" is not, and the import will not pretend otherwise. Every step past that is a similarity heuristic, and a similarity heuristic is a silently wrong card in a saved deck.

setField is a tie-breaker only. A single match on matchField is taken as-is and is never narrowed by set — a provider's set code need not equal the catalogue's, and narrowing a unique match would only invent a way for a correct entry to fail. When the broad match is ambiguous and both sides carry a set, the set narrows it; if narrowing finds nothing the broad list stands, so the outcome is a reported ambiguity rather than a false "no such card".

Nothing is guessed, and nothing is silently dropped#

Every entry ends in exactly one of three states:

  • resolved — exactly one catalogue card matched, and it becomes a decklist row. Two lines naming the same card in the same partition merge, because a provider that lists a card twice means "this many in total".
  • unresolved — nothing matched. Listed, with the value that failed, verbatim, so a person can search for it.
  • ambiguous — two or more matched. Also listed, and never resolved by picking one.

A decklist naming cards a catalogue lacks is normal: a new set, a typo, a provider whose card pool is wider than the game's. So the deck is created from what resolved and the rest is shown as a list the person can act on. A partial deck with a visible gap beats a refusal, and it beats a deck that is quietly wrong.

No capability is involved#

Deck import is initiated by a person in the deck builder, against an endpoint you declared, so no mod script runs and no mod capability is implicated. It is a first-party platform action with the same trust posture as /cards, and POST /api/plugins/:id/deck-import is unauthenticated for the same reason: what it returns is a function of the deck id the caller already typed and of nothing anybody holds. Saving the resulting deck still needs an account.

Three things follow that are worth planning for.

  • It spends your quota. The route sits under the plugin-catalogue rate-limit prefix, so an import draws on the dedicated inbound bucket and on a share of your plugin's daily budget — not on any one caller's.
  • The pasted value is checked against your declared parameter format before anything is composed. A deck link is reduced to its last path segment first, so a shared https://…/deck/AB12CD works against a token parameter, and the format check still runs on what is left. Nothing a person pastes can name a host: the destination is composed from your declared origin and path template exactly as it is everywhere else.
  • The server never learns which cards an import resolved to. It relays matchField and setField to the browser, and nothing more.

A game opts in from its own cardSchema.json#

"deckImport": { "pluginId": "com.example.deckdb" }

That names the plugin whose import block the deck builder offers for this game. A game whose cards already come from a plugin normally omits it — the deck builder falls back to source.pluginId — and names one here only to import from a different provider.

⚠ This is deliberately a separate reference from the mod manifest's plugins list. That list is the table script's declared reach and drags the plugin-call capability in with it, whereas a deck import involves no mod code at all.

The exposed API#

exposedApi is what a mod may call (at most 24 functions). Each entry names a declared endpoint, so the chain mod call → plugin function → endpoint → origin is followable statically by both the scanner and a human reviewer.

Field Rule
name Identifier, unique within the manifest. This is the string a mod passes to api.callPlugin.
summary 1–200 characters. A mod can read this; it is the only description of the function it gets.
endpoint Must name a declared endpoint, and that endpoint must be returns: "raw" — see below.
params A declared value schema. For a mod-callable function this must be an object whose member names are all parameters the endpoint declares.
returns A declared value schema. The response is validated against it before it reaches the caller.
acceptsTableData true marks a function as one that receives table-derived data. Requires combined, and is refused by the mod call path.

A declared value schema is data, not code — a manifest cannot ship a Zod object and must not ship a function. The platform compiles it server-side. Types are string (with optional maxLength or enum), number (with integer, min, max), boolean, array (with items and a required maxItems) and object (with fields, each optionally optional). Nesting is capped at five levels and 32 fields per object.

nullable — for a provider that sends null#

Any type may declare "nullable": true, which permits the value to be null.

optional and nullable are different claims and neither implies the other. optional says the key may be absent; nullable says its value may be null. Most REST APIs use null for "no value" rather than omitting the key, so a field like this needs both:

"format": { "schema": { "type": "string", "maxLength": 60, "nullable": true }, "optional": true }

Without nullable, a single null from your provider fails the whole call. If you only ever see a field populated in your own testing, prefer declaring it nullable anyway — the cost is nothing and the failure it prevents reaches players as a bare unavailable.

Strictness runs in only one direction#

The two sides of a call are validated differently, and deliberately so:

Compiled as Effect on an undeclared key
params (a mod's arguments, inbound) strict Rejected. A mod must not be able to smuggle a field past your declared surface.
returns (your provider's response, outbound) strip Dropped. A mod receives only the fields you declared.

So you do not need to enumerate every key your provider sends — declare the ones you want and the rest are discarded before any mod sees them. This also means a provider adding an unrelated field cannot break your plugin, which strict validation on this side used to cause.

Stripping does not soften the fields you did declare. A declared field with the wrong type, or a missing non-optional field, still fails the call.

There is no "forward this request" and no "send this payload" function shape. Either would launder a mod's traffic through your allowlist, which is the one thing this surface must not permit.

⚠ Do not point an exposed function at a returns: "cards" endpoint#

A card endpoint's response is not the provider's body — the platform runs your cardMapping over it and returns a normalized card page. That shape is chosen by the platform, so it cannot be described by your function's returns declaration, and a mod calling such a function gets { ok: false, reason: "refused" } every single time.

Point exposed functions at returns: "raw" endpoints. Your card endpoints are reached by the deck builder's prefetch, which is the surface they exist for — see calling a plugin from a mod.

Capabilities#

capabilities.allowed is one to three of network, table, combined. See the capability table for what each permits.

Consistency rules the manifest enforces:

  • origins and endpoints require network, and a network plugin must declare at least one origin.
  • tableScript requires table, and a table plugin must name a tableScript. It must be a repo-relative .js file with no traversal.
  • Your table script is scanned by the same scanModScriptSafety a mod's script is. It gets no more permissive rulebook for belonging to a plugin.

The combined capability#

combined means the data half may receive table-derived data from the table half, over a server-side channel that is schema-validated, logged and quota-accounted. It requires network and table as well — it is the union, declared explicitly — plus a combinedDisclosure block.

The warning text is platform-owned. Your manifest repeats it verbatim or it does not parse:

"capabilities": {
  "version": "1",
  "allowed": ["network", "table", "combined"],
  "combinedDisclosure": {
    "warningVersion": 1,
    "warning": "This plugin can read table state and send data to its own servers. A plugin with this capability, running on the host, is a cheating vector: it can observe hidden information such as other players' hands and face-down cards and transmit it off the table. Only enable it if you trust the author.",
    "reason": "Match results are posted to the Example League ladder after a game ends. Nothing else leaves the table."
  }
}

Author-owned warning text would be worthless, so the sentence is ours and you cannot soften it, shorten it, or reframe it as a feature. What you do own is reason (40–500 characters): the specific, reviewable claim about what leaves the table and why. That is the part a reviewer and a seated player can actually evaluate.

That warning is shown to every seated player, not only whoever installed the plugin, and it appears in the join flow before a player commits to the table. The reason is a consent asymmetry: the person who accepts is not the person at risk. The host installs and accepts; the hidden information that could leak belongs to the other players. A persistent indicator stays visible while such a plugin is active, and neither plugin nor mod code can suppress it.

combined is deliberately the hard path. If the split shape can do what you need, take it.

Credentials: never in the manifest#

Decision D-S2: the plugin author owns the provider relationship — its terms, its attribution, its commercial-use position, its rate-limit agreement, and its API credential. DiceyTable holds no relationship with any card-data provider.

A credential can never live in the manifest, and the manifest will not let you put one there:

  • Plugin source is public on GitHub by design — "the reviewed source is what runs" is only checkable if anyone can read it.
  • It is pinned to an immutable commit sha, so a leaked key cannot be rotated away by a force-push: the sha that leaked it is the sha that was reviewed and the sha that is served.
  • The registry API echoes the manifest back to unauthenticated callers.

Two mechanisms enforce it. Any key named apiKey, api_key, secret, token, password, authorization, credential, privateKey and their sixteen relatives is rejected at any depth with a manifest-secret issue that says why. Separately, any value that looks like a live credential — a Bearer … prefix, a ghp_…, a github_pat_…, an sk-live-…, an xoxb-…, an AKIA…, a JWT — is flagged even under an innocent key name.

What the manifest declares is only where a credential goes:

"auth": { "kind": "bearer" }
"auth": { "kind": "header", "headerName": "X-Api-Key" }
"auth": { "kind": "none" }

headerName must be a plain header token, and Authorization, Cookie, Host, Content-Length, Transfer-Encoding and Connection are refused — use kind: "bearer" for the first, and the rest are platform-controlled. There is no query kind, because credentials in query strings end up in access logs, referrers and upstream caches, and there is no way for us to un-log a provider's copy.

The value itself is written server-side through an authenticated route on your own plugin, stored encrypted, never returned to any client, never logged, and injected by the fetch layer at request time. It is not readable through your plugin's own declared surface either — you cannot read back your own key. A client can learn only whether one is configured and when it was last written.

A plugin whose provider needs no credential simply declares auth: { "kind": "none" } and runs on our address under our quota.

Quotas, and the neighbour you share an origin with#

rateLimits is your declaration of what your agreement with the provider permits:

"rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 2 }

This is a ceiling you accept, never a budget the platform grants. The fetch layer enforces min(declared, platform ceiling), so declaring a large number buys nothing and declaring a small one is honoured. It is written this way round because you hold the provider relationship and the credential, but the egress and the address reputation are ours.

Limit Platform ceiling Your declaration may be
requestsPerMinute 120 1–600
requestsPerDay 50,000 1–1,000,000
maxConcurrent 4 1–16 (defaults to 2)

Quotas are persistent — they survive a server restart, because an in-process counter is not a quota. They are also checked before the circuit breaker, so a greedy plugin normally exhausts its own budget before it can affect anyone else.

Separately, inbound catalogue reads have their own budget of 60 requests per minute per client address, spent across /api/plugins/:id/catalogue, /cards and /invoke. It is spent instead of the caller's general request budget rather than in addition to it, and that is the whole point of having it: without a separate bucket, a search-heavy plugin would silently exhaust a user's general budget and unrelated features would start failing for reasons nobody could see. It is a dedicated budget, not an exemption — the requests are still counted.

The circuit breaker is shared per origin#

Two plugins that declare the same hostname share one circuit breaker, and one can trip it for the other. This is the noisy-neighbour effect, and it is a deliberate trade, not an oversight.

The breaker exists to protect our egress address reputation, and the party who would ban us sees only our address — it cannot tell which plugin sent the traffic. A per-plugin breaker would fail to protect the one thing it exists for. The per-plugin quota being the first line is what makes cross-tenant denial rare rather than routine.

Behaviour Value
Consecutive upstream failures that open the circuit 5
Time open before a single probe is allowed through 30 seconds, backing off exponentially to 5 minutes
While open Requests fail immediately and cheaply; nothing queues

What counts as a failure is narrow on purpose: 5xx, 429 and 408 trip it. A 404 does not — a card that does not exist is a correct, cheap, healthy answer, and counting it would let one plugin's typo'd lookups deny an origin for everyone. 401 and 403 do not — the upstream is up and answering, and backing off the whole origin fixes nobody's expired key. Other 4xx do not either, for the same reason: our request was wrong, not theirs.

Practical consequence: if your provider is one that many plugins use, keep your failure rate low. A plugin that reliably asks for things its provider rejects with a 5xx is a plugin that periodically takes the origin down for every other plugin on it.

Responses are cached#

Successful responses are cached by (pluginId, endpoint, normalized params), storing the normalized shape rather than the provider's envelope. Upstream cache headers are honoured within a five-minute default and a one-hour ceiling, and a stale entry is retained for a day so it can be served after a failure or a quota refusal.

When that happens the deck builder receives stale: true and ageMs, which is exactly enough to render "showing cached results from 12 minutes ago" instead of an empty page.

A mod calling your function receives neither. The cache is keyed only on the plugin, endpoint and parameters — not on who asked — so it is shared by every room and every user. Telling a script whether its query hit the cache would tell it whether anyone else had recently run the same query, and with a stale entry kept for a day, one bit per card name adds up to another table's decklist. The first-party UI gets the staleness banner because it is showing it to a person; api.callPlugin returns { ok: true, data } and nothing else.

What a caller learns when something fails#

Four words, in a closed enum, carrying nothing else:

Reason Means
unavailable The provider did not answer usefully, or a server-side step failed.
rate-limited A quota or the shared breaker refused the request.
not-found No such plugin, endpoint or function is reachable from here.
refused The request or the response did not satisfy a declared schema.

There is no message, no status code, no hostname and no upstream body — a caller that could read those could probe your provider, and rate-limited is additionally made indistinguishable from unavailable in timing, so it cannot be used to measure a remaining budget or (because the breaker is shared) to infer another tenant's activity.

The real cause is not lost. It goes host-side to the author-facing diagnostics, scrubbed of your credential, where you can read it and untrusted code cannot.

Practical notes the schema will not tell you#

Declare a page parameter, or a big deck will not fully resolve#

A plugin-sourced game resolves the whole deck's cards up front, on load. The platform's own loader never falls back to a per-card request: an id absent from the prefetched set renders as a visibly unresolved row and stays that way, because per-card request timing and ordering are chosen by whoever wants the card, which would make them a channel out of a table's hidden state.

That is a property of the loader, not a guarantee about the plugin surface. If you declare an ordinary by-id card endpoint — path: "/v1/cards/{id}", the shape most card APIs have — then GET /api/plugins/:id/cards?endpoint=byId&p.id=… is a per-card lookup, reachable without a signed-in caller. The declared parameter composes exactly as designed and nothing forbids it; the rule was not made structural because many providers offer only by-id access, and excluding them would cost more than it bought. What bounds the residual is your per-plugin quota, the shared per-origin breaker and your declared-origin allowlist — not the request shape. Declare a by-id card endpoint only when your provider offers no bulk or search access, and prefer the bulk one when it does.

The prefetch walks at most eight pages, and it can only page when two things are both true:

  1. Your cardMapping declares nextPagePath, so the platform can read the provider's cursor.
  2. The game's source.options declares _pageParam, naming the endpoint parameter the cursor should be fed back into.

Without either, the walk stops after the first page and reports itself as truncated. A decklist entry outside the resolved set then renders as a visibly unresolved row and stays that way — it never triggers a follow-up request.

So: declare nextPagePath on any endpoint that can return more than one page, declare a paging parameter on the endpoint, and tell mod authors to set _pageParam. Mod authors should also scope source.options narrowly — a query that returns the game's whole card universe is a query that will truncate.

The game's source.options bag reserves exactly three keys, all beginning with _ (which no declared parameter name may start with): _endpoint picks a card endpoint by name, _searchParam names the parameter the search box feeds, _pageParam names the paging parameter. Everything else in the bag is a declared parameter value, passed through untouched.

There is no /search route#

GET /api/plugins/:id/cards serves both the deck prefetch and the deck-builder search. They are the same request shape with different declared parameter values, which is precisely what makes the request pattern a function of the plugin and the query rather than of which cards anybody holds. Do not design around a separate search endpoint on our side; declare one upstream endpoint and let the search box feed a declared parameter through _searchParam.

The two routes a browser reaches are:

  • GET /api/plugins/:id/catalogue — registry data only. Names your card endpoints, their declared parameters, your attribution and terms URL. Makes no upstream request and spends no quota. A blocked or incompatible plugin is returned here carrying its status rather than 404'd, so a page can say "the plugin this deck's cards come from has been withdrawn" instead of showing the same blank as a mistyped id.
  • GET /api/plugins/:id/cards?endpoint=<name>&p.<param>=<value> — one page of normalized cards. An unknown query key is a 400 rather than something ignored: ignoring it would let a caller believe it had constrained a search it had not, which for a paid provider is somebody else's bill.

POST /api/plugins/:id/invoke is the third, and it is the mod call path. It requires a signed-in caller, accepts only a function name (never an endpoint — the server re-derives that from your manifest), and refuses acceptsTableData functions.

Attribution and terms are not optional#

attribution (1–300 characters) is displayed wherever your plugin's data appears, and termsUrl must be an https:// URL for the provider terms you accepted on your own behalf. Most card providers' terms demand credit; the author is the party who agreed to them, so the text is yours to get right and ours to render mechanically.

Testing a change before you publish#

There is no dry-run endpoint today. What there is:

  1. Validate the manifest locally. Registration runs validatePluginManifest, which reports every issue at once with a path per issue. Registering against a scratch plugin id in a throwaway repository gives you the same report without touching your live plugin.
  2. Register the exact commit sha. Registration pins a sha, and any sha change re-triggers review — so a branch you push to is not what runs until it is registered. Register the commit you actually intend to ship.
  3. Read GET /api/plugins/:id/catalogue. It confirms which endpoints the platform sees, which parameters it will accept, and which mapped field it will treat as the card identity — all without spending a request against your provider.
  4. Then read GET /api/plugins/:id/cards. This one does hit the provider. Check the mapped card shape, and check that hasMore/nextPageToken behave the way your nextPagePath claims.
  5. Diff the identity values. If any card's identity value changed, you have made a breaking change whether you meant to or not. See breakingVersion and migration before you publish it.

See also#