Dicey Table

Sandbox limits

Scripts on both surfaces run inside an iframe with sandbox="allow-scripts" and no allow-same-origin, which forces an opaque null origin, under a document CSP of default-src 'none'. The frame reaches the table through postMessage and reaches nothing else.

Three separate mechanisms constrain what you can write, and they fail at three different moments. Knowing which one you have hit is most of the debugging:

Mechanism When it stops you What it looks like
The type system While you type, in the editor A red squiggle: Cannot find name 'document'.
The frame's neutering At run time A thrown error, or an undefined where you expected a global
The static scanner When a mod is validated, published, registered or served A rejection naming a rule code, such as dom-access

The language subset#

Table scripts are TypeScript, compiled in the editor with exactly these options (apps/web/src/ui/editor/monacoSetup.ts):

target:        ES2020
lib:           ["es2020"]        <- deliberately NO "dom"
strict:        true
noImplicitAny: true

The scripting API declarations are loaded as an ambient library on top. That is the whole environment. What follows from it:

  • document, window, console, fetch, localStorage, matchMedia and every other browser global are compile errors — not because a rule forbids them, but because no declaration for them exists. world.log(message) is how a table script produces output; api.log(message) is the mod equivalent.
  • In a table script setTimeout is a compile error, and still runs. The frame provides it at run time, but lib: ["es2020"] does not declare it and neither does the table-scripting library. Use await world.wait(seconds), which is declared, takes seconds rather than milliseconds, and reads as pacing rather than plumbing. (A mod script is different — see below.)
  • A script is a plain top-level program. No import, no export. Adding either turns the file into a module and changes what every top-level declaration means.
  • No top-level await. ES2020 in a non-module does not have it. Wrap async work in an async function and start it with void myFunction();.
  • noUncheckedIndexedAccess is off, so array[0] has type T, not T | undefined. Guard an index you are not sure about yourself.

A type error does not stop a script from running. The editor emits the transpiled body even when the type checker complains, so a script with cosmetic type problems still executes — a syntax error is what produces no output at all, and a script with no compiled body never runs. Read the diagnostics bar; it is telling you something real even when ▶ Play still works.

Mod scripts are plain JavaScript and are never transpiled — the file in your repository is the file that runs, and entry.script must end .js. The same absent-globals list applies for the same reason, with the same api object as the only way out of the frame — with three declared exceptions, because the mod frame really provides them and the editor typechecks against lib: ["es2020"] with no @types: setTimeout, clearTimeout and a two-member crypto (randomUUID, getRandomValues). The looping timers are still refused by the scanner, which is why they are not declared.

What the frame removes at run time#

Both sandbox documents overwrite the same escape hatches before any script body evaluates, using Object.defineProperty so that a getter-only accessor cannot throw and abort the bootstrap:

Replaced with a thrower Replaced with undefined
fetch · open · alert XMLHttpRequest · WebSocket · EventSource · localStorage · sessionStorage · indexedDB

Calling a thrower raises API is not available inside script sandbox. (mod frame: … inside mod sandbox.). This is belt-and-braces — the opaque origin already makes the storage accessors throw and the CSP already blocks every network destination — and it is what makes the failure legible instead of mysterious.

The static scanner, and why it reads your comments#

Five regular expressions are run over script text. A single match rejects the file.

Code Tokens that reject it
dom-access document window parent top opener
network-access fetch XMLHttpRequest WebSocket EventSource
storage-access localStorage sessionStorage indexedDB cookie
dynamic-code eval Function importScripts
timer-loop setInterval requestAnimationFrame

Full detail, including every rejection message and the per-rule workaround, is on Script safety. Two facts belong here because they surprise people writing table scripts, who reasonably assume the scanner is a mod-publishing concern.

Table scripts are scanned too. validateSceneScriptSafety (packages/shared/src/modManifest.ts) runs the same five patterns over every scene script embedded in a scene document, and the server refuses to serve a mod whose scene scripts stop passing — "Mod scene scripts no longer pass sandbox compatibility checks." (apps/server/src/githubScanner.ts).

It scans the compiled body, and the compiled body still has your comments in it. The editor produces the transpiled JavaScript with Monaco's getEmitOutput, which does not strip comments, and it is that text the scanner reads. So a comment reaches the five patterns verbatim.

The word that catches people is top. // draw the top card rejects the whole scene. So does // re-attach to the parent and // don't touch the window.

Your script still runs locally in Edit Mode when it contains one of these — nothing scans on ▶ Play. The rejection arrives later, when you publish the mod or when a player's client asks the server for it, which is exactly the worst moment to discover it. Check your comments before you publish.

The boundary rule, and the reason for it#

By design. The five patterns are whole-word regular expressions applied to raw text with no lexing. They cannot tell a comment from code, a string from an identifier, or a property from a global. The scanner's own note states the trade: "false positives are acceptable (mod authors can adjust scripts), while false negatives are treated as boundary risks and should trigger a rule update." That is a security boundary and it is not expected to loosen. Rename or rephrase; there is no flag, no opt-out and no escape syntax, and none should be added.

What does not trip a rule#

\b needs a non-word character on both sides, so gluing letters, digits or an underscore onto either end removes the boundary. All of these are safe:

topCard    deckTop    stackTop     parentZone    parentId    myParent
fetchCount containerZone           localStorageFallback      function

The patterns are case-sensitive: Function is banned, function is not; Top is fine, top is not. So the fix is always a rename or a rephrase:

Instead of Write
const top = deck.pop(); const topCard = deck.pop();
// the top of the deck // the front of the deck
// re-attach to the parent // re-attach to the ancestor
@param {Function} handler @param {() => void} handler
// don't fetch the next card // don't draw the next card
setInterval(tidyUp, 1000) an onTick handler, or await world.wait(1) in a loop

Capabilities — mod scripting only#

Table scripting has no capability system. Its restriction is a three-type intent allowlist (spawn, object-action, transform) enforced on the host, and that is the whole of it.

Mod scripting declares capabilities in its manifest, and every one of the 23 api methods is gated on one. The complete capability-to-method matrix, the detector regexes and the exact rejection messages live on Mod capabilities; this page does not repeat them.

What is worth understanding here is what a capability declaration is for. It is a scanned declaration of intent, verified against the literal api.<method>( call sites in the script that was scanned. It is a least-privilege and disclosure mechanism: a reviewer or a player can read a mod's declared list and know what it can reach before opening a line of its code.

At run time nine of the eleven are checked twice. The Missing mod capability: … throw executes inside the same untrusted frame as the mod's own script, so it is the error you see while developing rather than a boundary — a script that posts to the host directly never runs it. The host then re-validates every gated message against the mod's grants before acting on it, using the shared MOD_API_METHOD_CAPABILITIES map (apps/web/src/mods/SandboxedModRunner.ts), the same way it re-verifies an object action or a sound reference. That second check is the authoritative one. It covers log, spawn-object, register-action, read-world, read-hidden-information, object-action, play-sound, saved-data and ui.

One of the eleven is a wall rather than a disclosure. read-hidden-information gates api.getUnredactedSnapshot() and nothing else, and it is the only route to the host's unredacted table state: all six read-world reads return the least-privileged view — the one a spectator with no seat and no team gets — on every peer including the host, so a mod cannot reach a hidden card face, a pile's order or any secretMetadata by arranging to run on the host. Its host-side check runs before the state is served, it is never implied by read-world, it is never granted by default, and the publish scanner matches the call by name. It is the one grant whose absence genuinely withholds data.

read-context and subscribe-events are checked in the frame only, because neither sends the host anything to check: getMySeat, getMyTeam, getTurn and on are all answered in-frame from state the host has already delivered. updateContext and dispatchEvent push a contextUpdate and a hookEvent to every running frame unconditionally, so a frame granted neither capability still holds the seat, team and turn context and receives every hook payload in its own realm. For those two, the declaration is disclosure and not a wall — design accordingly, and do not treat a withheld read-context as hiding anything.

None of this makes a declaration a statement about a mod's intentions: a manifest may still declare more than its script uses.

The boundaries that are independently enforced, and that a capability declaration has no bearing on, remain intact: the opaque-origin iframe and its CSP, the neutered globals above, the five scanner patterns, the host-side object-action allowlist, the per-mod namespacing of saved data, and the ownership check on sound references. A mod cannot reach fetch, localStorage, eval, another mod's saved data, or an object action outside its 10-item list, whatever its manifest says.

The capability values#

The capability badge names which manifest-declared capability gates a call.

none#

Every table-scripting entry. The surface has no capability model, so there are no exceptions: if you are reading a table-scripting entry with any other value, the page is wrong.

The eleven mod slugs#

log · spawn-object · register-action · read-context · read-world · read-hidden-information · object-action · saved-data · subscribe-events · ui · play-sound.

Every mod entry carries exactly one of these — the slug in that method's own requireCapability('…') call — so none is never correct on a mod entry. log is the only one a manifest gets without asking: omit capabilities entirely and a mod is granted log and nothing else.

See also#