# Asset Delivery and the CDN

> How mod files and declared plugin resources reach browsers through cdn.diceytable.com, the rules the edge enforces, and what stays on GitHub.

- Source: https://diceytable.com/docs/mods/publishing/asset-delivery
- Last verified: 2026-09-19

Your mod's files live in your GitHub repo and never move. But the *road* they take to a player's
browser is not always the same one, and which road they take is decided entirely by **how you
reference them**. This page is the rule set.

## Two roads

At play time every peer independently pulls your published mod and caches it locally
(see [Registration & Multiplayer Play](https://diceytable.com/docs/mods/publishing/registration-and-play)). Anything a
peer does not have locally — a file the manifest never declared (including one you set to load
[on demand](https://diceytable.com/docs/mods/manifest/reference#excludedassets)), a cache that was evicted, a
device that just joined — is fetched over the network instead. That fetch goes one of two ways:

- **`raw.githubusercontent.com`** — the public GitHub URL. Always works. It is the fallback the
  platform has always used and it is not going away.
- **`cdn.diceytable.com`** — a proxy in front of exactly that content, on the same edge network
  that serves the rest of the app.

Nothing is copied or re-hosted. The CDN fetches the same bytes from the same GitHub URL; it just
sits closer to the player and remembers the answer.

The pull itself takes the CDN road too. The play-mode pre-pull of your published tree, pack
documents and card catalogues are fetched through the edge. If the edge answers with anything but
success (a pack registered seconds ago, before the allowlist refreshes, or an edge outage), that
file is fetched again from `raw.githubusercontent.com`. A slow load is better than a missing file.

## Why the CDN is worth having

Measured on a live table serving a large card game: **8.8 MB of card art arrived over HTTP/2 from
a Fastly-fronted origin advertising no `alt-svc`**, while every first-party byte on the same page
came over HTTP/3 from an edge a few milliseconds away. Same page, same moment, two very different
roads.

Routing mod assets through the CDN buys three things:

- **Edge termination and HTTP/3.** The connection is set up against a nearby point of presence
  rather than a distant origin — the difference is largest on exactly the connections that need it
  most: mobile, high-latency, lossy.
- **A real cache.** A commit-pinned file is cached for a year. The second player to join a table
  does not re-fetch what the first one already pulled through that edge.
- **Fewer trips to GitHub.** Cache hits never reach `raw.githubusercontent.com` at all, which
  keeps you further away from GitHub's own limits on a busy table.

## You do not opt in — you can only opt *out*

There is no CDN URL to write, no manifest key, and no setting. **Reference a file by its
repo-relative path and the platform routes it for you:**

```json
{ "model": "models/pawn.glb", "texture": "assets/textures/pawn-basecolor.png" }
```

The resolver turns a repo-relative path into `raw.githubusercontent.com/{owner}/{repo}/{ref}/...`
and then swaps the origin for the CDN. Same path, same query string, same everything — a pure
origin swap.

The same swap applies to your pack's page media (the cover image, screenshots and rulebook images,
wherever the site shows them, including the home page) and to repo-relative card art in a
catalogue. **File types the edge refuses stay on GitHub.** Scripts, and `.md` files outside
`rules/`, are never rewritten, so a fetch that works from GitHub is never sent where it would 404.

> **The one way to lose it: writing an absolute URL yourself.**
>
> A value that is already a full `https://raw.githubusercontent.com/...` URL is treated as
> "already loadable" and handed straight to the browser. It never reaches the rewrite, so it never
> reaches the CDN — and because that is indistinguishable from any other absolute URL, nothing
> warns you.
>
> This matters most for **card catalogues**, where it is tempting to store a complete image URL per
> row. If those rows name your own repo, store the repo-relative path instead. If they name someone
> else's repo, [declare it](https://diceytable.com/docs/mods/publishing/asset-delivery#art-in-somebody-elses-repository) and the platform will rewrite those
> absolute URLs for you.

## What the edge enforces

Every request is checked before a single byte is fetched from GitHub.

| Rule | Detail |
|---|---|
| **Registered or declared repos only** | The coordinate must appear on the platform's published allowlist, which is built from the repos of registered packs plus the outside repos those packs [declared](https://diceytable.com/docs/mods/publishing/asset-delivery#art-in-somebody-elses-repository). A plugin's own repository contributes only its [declared resource files](https://diceytable.com/docs/mods/publishing/asset-delivery#plugin-resources-exact-paths-at-a-pinned-commit), path by path. Everything else is refused. |
| **Exact ref match** | A mod registered at commit `abc123` does not authorise its own `main`, and vice versa. Those are different bytes, which is the whole reason a registration pins a commit. The full commit sha of **every published version** of a pack is admitted too, so a room pinned to an older version is still served from the edge. |
| **Extension allowlist** | The same 18 extensions a mod may publish — see [Assets](https://diceytable.com/docs/mods/files/assets). Anything else is refused *before* an upstream request is made, so a scan for `/.env` costs nothing. |
| **`GET` and `HEAD` only** | Plus `OPTIONS` for preflight. Anything else is `405`. |
| **64 MB per file** | A larger file is refused rather than streamed. Mod assets are far below this. |
| **Content-Type is decided here** | Re-asserted from the extension, never inherited — GitHub serves much of this as `text/plain`. Sent with `X-Content-Type-Options: nosniff`. |
| **Responses are inert** | `Content-Security-Policy: default-src 'none'; sandbox`, `Referrer-Policy: no-referrer`, `Cross-Origin-Resource-Policy: cross-origin`, and `Access-Control-Allow-Origin: *` so the table runtime can `fetch` bytes to compose sprite sheets. |

**A refusal is a `404`, not a `403`** — whether a particular repo is registered is not a fact this
endpoint confirms to anonymous callers. The response body names the reason in plain text.

### Why the allowlist exists

An unrestricted `cdn.diceytable.com/{owner}/{repo}/{ref}/{path}` would be a free, anonymous CDN for
arbitrary GitHub content, served from our domain and paid for by our bandwidth. The proxy is
defensible only because every coordinate on it is one a person can trace to a pack: either the pack's
own scanned repository, or one that pack explicitly named in its manifest. That is the design, not a
hardening pass on top of it — so "please allow my repo" is answered by registering a pack from it or
declaring it from one, never by an exception.

## Art in somebody else’s repository

A card catalogue routinely reads art from a repository the pack does not own — community-maintained
set images, say. Those rows carry absolute `raw.githubusercontent.com` URLs, so they take neither of
the roads above, and the edge would refuse them because the repository behind them is not one this
platform registered.

Declare it and both problems go away:

```json
"assetRepos": [
  { "owner": "SWTCG", "repo": "SWTCG-LACKEY", "ref": "refs/heads/release" }
]
```

The same block works in a mod manifest and in a plugin manifest. What it does:

- puts that coordinate on the edge’s allowlist, and
- lets the client rewrite absolute GitHub URLs naming it onto the CDN — including the ones inside
  your catalogue, which nothing else could reach.

It grants no read a browser did not already have: everything it admits is public on GitHub and
fetchable from the same URL by the same browser. What it changes is whose bytes we serve from our
domain, which is why it carries four rules:

| Rule | Detail |
|---|---|
| **At most four repos per manifest** | The cap is the abuse control. A pack pulling art from five different strangers is a pack a person should look at. |
| **Declared, never inferred** | The edge will not read your catalogue and work the hosts out for itself. An entry is a line in a public, commit-pinned manifest that a reviewer reads once — which is why publishing [writes the line for you](https://diceytable.com/docs/mods/publishing/asset-delivery#written-for-you-at-publish) rather than the edge guessing it. |
| **Shown on your pack’s page** | Under *Serves art from*, with the ref, so a player can see whose repositories a table is pulling from. |
| **The ref matches exactly** | Declaring `main` does not authorise a sha and a sha does not authorise `main`. Declare the ref your URLs actually address. |

**A branch is allowed, a commit is better.** Registration emits an `asset-repo-unpinned` warning for
a branch rather than refusing it, because you usually cannot pin somebody else’s repository — the day
they publish a new set, every card in it renders blank until you re-publish your pack just to move
the pin. The warning is telling you about caching: the edge holds a commit-addressed file for a year
and a branch-addressed one for minutes, and a branch’s contents can change without your pack being
re-published or re-reviewed. Pin a commit whenever the repository is one you control — and pin the
**full** 40-character sha: an abbreviated one also draws the warning and is cached like a branch,
because a branch can carry that name.

### Written for you at publish

When you publish from the editor, the platform reads every card catalogue (`*.cards.json`) your
manifest lists under `assets` and adds to `assetRepos` each GitHub repository and ref their art
URLs address that the manifest does not already declare. The coordinate is copied from the URLs
exactly as written, ref included. The entry lands in the committed `diceytable.mod.json`, so it is
the same reviewable declaration it would be if you had typed it; the edge still reads only the
manifest.

- **It only adds.** Entries you wrote are kept exactly as written, including one that no URL uses
  any more (a `master` entry left over after your art moved to `refs/heads/release`, say). Remove
  those yourself; they still count toward the cap of four.
- **It stops at the cap.** Coordinates that would take the list past four, or that are not a valid
  declaration, are left out, and the `catalogue-art-undeclared` warning below names them.
- **It is stable.** New entries are appended in sorted order, so republishing an unchanged pack
  produces the same file.

Declare by hand anything else: art your pack reaches **other than through a card catalogue**, and
every outside repository of a pack you publish without the editor.

**A mismatch is a warning, not a failure — read it.** If your card catalogue’s art points at a
repository, or a *ref*, that `assetRepos` does not cover, registration emits a
`catalogue-art-undeclared` warning naming what it found. Nothing breaks when you ignore it and that
is exactly the problem: an undeclared URL is passed through untouched, so every card still renders
and the only symptom is that each player downloads your card art from someone else’s repository on
every cold load. The commonest cause is a ref that is nearly right — declaring `master` while the
URLs say `refs/heads/release` routes nothing at all, because the ref is compared literally.
Publishing from the editor fixes that case for you. If you still see the warning, the pack was
published some other way, it already declares four repositories, or the catalogue could not be read
at publish time (larger than 16 MB, or GitHub did not answer) — republish, or declare the entry by hand.

Declaring dies with the pack. A blocked pack’s declarations stop being served with it, and a pack
switched off the edge takes its declared repos off with it too.

### From a plugin

A plugin declares the same block, and additionally has to name the CDN as the art origin:

```json
"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" }
  }
}
```

The first three segments of that path must be **literal**, and they must name a repo in
`assetRepos`. Both are checked when the plugin registers, and both are errors rather than warnings:
they are decidable from the manifest alone, and getting either wrong means every card 404s with
nothing to fall back to. A plugin’s own repository is never on the allowlist *as a repository*:
the only files from it a browser may fetch through the edge are the ones it declares under
`resources`, described next.

## Plugin resources: exact paths at a pinned commit

A plugin may declare resource files — models, textures, materials, deck definitions and their
thumbnails — under `resources` in `diceytable.plugin.json`
([Writing a plugin](https://diceytable.com/docs/plugins/authoring#resources-files-a-project-can-copy)). Those files, and
only those, are admitted from the plugin's own repository, under rules narrower than a mod's:

| Rule | Detail |
|---|---|
| **Exact paths, never the repo** | Each declared `path` and `thumbnail` is listed individually. The request path is compared byte-for-byte as it arrived — still percent-encoded, case-sensitive — so an undeclared sibling in the same folder at the same commit is refused, and a re-cased or re-encoded spelling of a declared path does not match. |
| **The registered commit sha, never a branch** | The grant names the plugin's registered commit. A path grant at a branch would authorise whatever that branch points at next, which nobody scanned, so the edge drops any grant whose ref is not a commit sha. Being commit-addressed, these files get the year-long immutable cache. |
| **Scanned, not merely declared** | A path is listed only if the scan fetched it at that commit and it passed every check (size, content type, magic bytes, and the reference rules for models, materials and decks). |
| **The bytes are the commit's bytes** | During the scan, each file's git blob sha must match the one GitHub's git data API lists for that path in the registered commit. A file that differs, or that the commit does not contain, is refused. The grant carries that sha. The edge hashes what GitHub returns, serves it only on an exact match, and caches only a copy that matched. Anything else is refused with a `502` that is never cached. Files are limited to 50 MB, the same limit the scan applies. |
| **Compatible plugins only** | A plugin whose scan came back incompatible, or that an administrator blocked, contributes no paths at all. |
| **Registry-wide, not per room** | The edge cannot know which project or room a request belongs to, and these files are public, sha-pinned and reviewed. Whether a resource is *used* is decided in the editor — a registered plugin is inert until a project lists it. |

Path grants travel as a separate list on the allowlist rather than as a field on a repository entry.
An edge older than this contract therefore ignores them and refuses the files — it fails closed —
instead of reading a path grant as a grant for the whole repository, or serving a file it cannot check.

The content check exists because a commit sha in a raw URL is still a name that GitHub resolves. The
scan matches each file to the commit by object id, and the edge pins the bytes to that match. So the file
a player receives is the file in the registered commit, however that name resolves. A pack's own files and the repositories in `assetRepos` have no per-file record, so they are
served as fetched, under the ref rules above.

These files are for the editor and renderer. **They are not a network path for a mod script**, which
still has none.

## Caching and cache busting

| Ref | `Cache-Control` |
|---|---|
| A **full 40-character commit sha that GitHub confirmed is a commit** (`12e39c5fba609d948187326a5bca50609e328aaa`) | `public, max-age=31536000, immutable` — a year. The bytes can never change. |
| Anything else: a branch (`main`, `refs/heads/release`), a tag, an abbreviated sha (`12e39c5`), or a 40-character ref that has not been confirmed | `public, max-age=300, stale-while-revalidate=86400` |

**A sha's shape is not enough for the long cache.** GitHub resolves the ref in a raw URL against
branch and tag names as well as commits, so `cafe1234` — or 40 hex characters — can be a branch that
moves. The platform asks GitHub's commits API whether each full sha on the allowlist is really a
commit in that repository, and only a confirmed one is cached for a year. This applies to your pack's
registered sha and to the refs in `assetRepos` alike. Your pack's own sha is confirmed during
registration, so it gets the long cache as soon as the allowlist refreshes. Other shas, such as those
in `assetRepos` and any sha after a server restart, are confirmed in the background, and only for
packs that scanned `compatible`. Until that happens they are served with the short cache; nothing
breaks while it does.

A branch-addressed URL is byte-identical before and after you publish, so a long cache would serve
your old art indefinitely. The platform appends `?v=<contentVersion>` to branch-addressed asset
URLs, and the query string is part of the cache key — a republish is therefore a *new object*, not
a stale hit. Refusals are `no-store`, so an asset that 404s only because the allowlist had not
refreshed yet does not stay broken.

## What the CDN will *not* serve

Each of these falls back to GitHub, is refused, or never enters the rewrite in the first place.
None of them is a bug.

- **A repo that is neither a registered pack nor declared by one.** An undeclared third-party art
  host is refused with a `404`; leave those URLs pointing at `raw.githubusercontent.com`, or
  [declare the repo](https://diceytable.com/docs/mods/publishing/asset-delivery#art-in-somebody-elses-repository).
- **A blocked pack, and everything it declared.** Taken down means taken down, on every road.
- **A ref other than the registered one** or a published version's full commit sha.
- **Any file in a plugin's repository that is not a declared, scanned resource** — including the
  plugin's manifest, its table script, and a declared file whose scan failed.
- **An extension outside the 18.**
- **Anything whose job is to observe GitHub's *current* state.** The pack liveness probe, fresh
  verification and the publish scanner all deliberately read GitHub directly with
  `Cache-Control: no-cache`. Putting a cache in front of "is this repo still there, right now"
  turns a verified-fresh answer into a verified-stale one, and a scan of a cached copy is a scan of
  something other than what will run.
- **Local development and PR previews.** These run with no CDN origin configured, which is a
  supported mode: every URL passes straight through to GitHub. A deployment sets the origin in two
  places, `VITE_MOD_ASSET_CDN` for the web client and `MOD_ASSET_CDN_ORIGIN` for the server-rendered
  game pages, and the two must name the same origin. It is also why a preview deployment
  testing an unregistered repo still works.

A mod can also be **switched off the edge by an administrator**. That is not a refusal — the edge
answers a `302` redirect to the GitHub URL, cached as aggressively as the asset itself, so the pack
stays fully playable and the bytes simply travel GitHub → browser without touching our bandwidth.

## Rate limits

**There is no per-client rate limit at the edge, and no quota you can exhaust.** The ceilings that
do exist are the ones in the table above: the allowlist, the extension list, the 64 MB per-file
cap, and the method restriction. Beyond those, requests are subject to the CDN platform's own
generic protections, and — on a cache *miss* only — to GitHub's ordinary limits on
`raw.githubusercontent.com`. Cache hits never reach GitHub, which is one of the reasons the CDN
helps a busy table rather than merely a distant one.

If the allowlist service is unreachable, the edge keeps honouring the last copy it successfully
fetched rather than failing closed: a registry blip must not black out every custom texture on
every table. It fails closed only when it has never held a copy at all.

## Checking which road your assets took

Open the browser console on a table. The app reports its asset routing once at startup:

```
[mod-assets] serving from CDN https://cdn.diceytable.com
```

or

```
[mod-assets] no VITE_MOD_ASSET_CDN in this build; serving from raw.githubusercontent.com
```

Then open the network panel and filter by domain. Requests still going to
`raw.githubusercontent.com` while the first line says the CDN is active are the interesting ones —
each will be an absolute URL written by hand, an undeclared third-party repo, a script file, one
of the deliberate freshness reads listed above, or a fallback after the edge refused a file. A
fallback is logged as `[mod-assets] CDN answered <status> for <url>; falling back to GitHub.`

A file that is simply not in your repository at that commit is not a fallback: the edge asked
GitHub already, so the app takes that 404 as the answer and asks nobody else. It shows up as
one 404 on `cdn.diceytable.com`, with no warning and no second request.
