What Gets Rejected
DiceyTable enforces mod safety with 68 distinct rejection rules, spread across five
enforcement points that run at different times. This page lists every one of them —
the exact machine-readable code, the exact condition that trips it, and the exact
user-facing message — grouped the way the underlying source groups them. If you have
an error string in hand, use your browser's find-in-page on the code and you will land
on the right rule.
For the 5 script-content patterns specifically — the ones people hit by accident — see Script Safety. If you already have a code and just want the fix, see Fixing a Rejection.
Where a rule runs#
Every rule below fires at exactly one (occasionally two) of these five points. This
matters because the same code can mean "your draft won't save" in one context and
"your published mod won't load for a room" in another.
| Enforcement point | What it is | Runs |
|---|---|---|
| Local draft validation | validateLocalModProjectDraft (packages/shared/src/modManifest.ts) |
Continuously in the in-app editor, on every draft save (ProjectStore.prepareDraft, apps/server/src/store.ts), and in the browser before an unpublished Preview loads the draft. Never fetches GitHub. |
| Asset upload gate | validateUploadedAssetExtension (apps/server/src/githubScanner.ts) |
When you upload a file into a draft (ProjectStore.uploadProjectAsset, store.ts), before the payload is even decoded. |
| GitHub scan (registration) | scanGitHubMod (apps/server/src/githubScanner.ts) |
Once, when a mod's GitHub repo is registered or re-scanned. Fetches the manifest, then — only if the manifest itself passed — every declared asset and the entry script/setup, straight from raw.githubusercontent.com. |
| GitHub re-scan (serve time) | fetchGitHubModArtifact (apps/server/src/githubScanner.ts) |
Every time a compatible mod is actually loaded into a room. See the serve-time risk below — this is not the same moment as registration. |
| Registry resolution (publish) | ModService.upsertScannedMod (apps/server/src/services/modService.ts) and InMemoryStore.upsertScannedMod (apps/server/src/store.ts) |
Once per POST /api/mods/register, after the whole GitHub scan has finished. The only point that consults registry state — what else has been published, and at which commit. Nothing in the security boundary is skipped, reordered, or made conditional on what the registry answers. |
Rules that stop the scan early#
A manifest-level error — a bad path, an unsupported asset type, an incompatible engine range, or
unpinned-scan above — ends the scan before a single asset is fetched. Two consequences worth
knowing when you are reading a rejection:
- You will see the manifest problem only. Asset errors that would also have fired are not reported in the same pass, so fix the manifest and re-scan to see what is underneath.
assetCatalogcomes back empty for such a mod. That is expected, not a second failure.
The reason is not tidiness: a manifest may declare up to 2000 assets of up to 50 MB each, so scanning a mod that has already been disqualified turned one inbound registration into hundreds of upstream requests.
Why a message sometimes ends in ...#
Refusal text is capped before it is stored. capCompatibilityIssueMessage
(packages/shared/src/modManifest.ts) caps the message — the shared message builders call it
themselves, so an editor-surface issue is capped identically — and the GitHub scan applies a
matching cap to the path on its way out (apps/server/src/githubScanner.ts):
| Field | Cap | What you see past it |
|---|---|---|
message |
400 characters | The first 397 characters followed by ... |
path |
180 characters | The first 177 characters followed by ... |
Both caps come from compatibilityIssueSchema itself, so they are not a display truncation you can
scroll past — the stored text really is shorter. The one that bites in practice is a long script
name: a rule such as scene-script-undeclared-capability
interpolates the script's name into a message that is already close to 400 characters, and
component-pack script names come straight off repo JSON with no length bound of their own. So a
mod with a verbose script name can see the closing rationale of an otherwise-ordinary refusal cut
off, while an identical mod with a short name sees it in full.
Read the front of the message, not the back. Every message in the scanner is deliberately
written with its actionable clause first — "Add read-hidden-information to capabilities.allowed
in diceytable.mod.json and republish, or remove the call" — precisely so that the instruction
survives the cut and only the explanatory tail is lost. A truncated path still names the right
file; the missing characters are at the end of the locator, not the start.
This replaced a worse failure. An over-long message used to be written out whole and then dropped on the way back in: the server re-validates each stored issue when it reads it, an over-long one failed that validation, and the issue silently disappeared. The mod stayed
incompatiblewith no reason shown at all — a refusal whose explanation is the mitigation, reported as a clean scan. Truncation is strictly better than disappearance, which is why the cap exists rather than a looser schema.
Script-content patterns (5)#
These are checked by the identical rule set in two places — SANDBOX_SCANNER_RULES
(apps/server/src/githubScanner.ts) and bannedScriptPatterns
(packages/shared/src/modManifest.ts) — and fire during GitHub scan/re-scan
(scanScriptText) and local draft validation / scene-script validation
(scanModScriptSafety). They are documented in full, including the false-positive
trade-off and workarounds, on their own page:
Script Safety. See also the Surface B api
reference: /docs/scripting-api/api.
Commit pinning (1)#
unpinned-scan#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition: the scan cannot be pinned to a commit it has proven. Either the ref could not be resolved to a commit sha (registration prefers the sha the publisher supplies, falls back to an
api.github.comlookup, and both came up empty), or acommitShawas supplied and GitHub's commits API did not confirm it. A supplied sha must be the full 40 characters andGET /repos/{owner}/{repo}/commits/{sha}must answer with that exact sha. An abbreviated sha, a sha that is not a commit, a branch or tag literally named like a sha (the API resolves it to a different commit), and a lookup GitHub does not answer are all refused, and the sha is not recorded. - Message (unresolvable ref):
Could not resolve ${ref} to a commit, so this scan cannot be pinned. What was scanned is not guaranteed to be what is served. Set GITHUB_TOKEN on the server or re-submit with an explicit commit sha. - Message (abbreviated supplied sha):
A commit sha supplied with a mod registration must be the full 40 characters; an abbreviated sha can also name a branch or tag. Omit it to pin whatever the ref resolves to. - Message (not a commit):
The supplied sha is not a commit in ${owner}/${repo}. Register the exact commit sha.— with(it resolves as a branch or tag name)before the full stop when GitHub answered with a different sha. - Not a rejection — GitHub did not answer. If the commits lookup is rate-limited (401, 403, 408, 429), returns a 5xx, fails on the network or times out, registration is not recorded as
unpinned-scan. A definite answer (404, 409 empty repository, 410, 422, 451) is still a refusal.POST /api/mods/registeranswers503with{ "error": "github-unavailable", "retryable": true }and aRetry-Afterheader, and the existing record for the mod is left exactly as it was, so a GitHub hiccup during publish cannot take a working pack offline. Retry the registration. - A just-merged commit. If the commits endpoint briefly answers 404 for a sha you merged a moment ago, registration also looks up the ref you registered at. If that ref's head is exactly your sha, the sha is accepted. A 404 is refused only when the ref points somewhere else or does not exist.
Why this is an error and not a warning. Every other rule on this page asks "is this mod safe?" This one asks "will the mod we just checked be the mod we later serve?" — and without a sha the honest answer is no. The scan would read one commit off a branch and the artifact would be served from wherever that branch points later, so a repo owner could pass review and then change what runs. A warning would have left the mod installable, which is the same as not having the rule.
Why a supplied sha is looked up rather than trusted. GitHub resolves the ref in a
raw.githubusercontent.com URL against branch and tag names as well as commits. A value that merely
looks like a sha — cafe1234, or 40 hex characters — can be a branch, and a branch moves. Accepting
it on shape alone would scan whatever the branch holds today and record it as a permanent pin. This is
the same rule plugins follow.
If you are self-hosting and every scan trips this, the server is missing GITHUB_TOKEN: the commit
lookup is unauthenticated without it and GitHub allows only 60 such requests per hour for the whole
server. Publishing through DiceyTable's own flow supplies the sha directly and never needs the lookup.
Reserved ids (1)#
reserved-pack-id#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only, decided from the manifest before any asset is fetched. - Condition: the manifest's
idis the id of a pack built into DiceyTable — todaydiceytable.dining-table, the built-in Dining Table. Compared case-insensitively. The list is the sharedBUILT_IN_TABLE_PACKSexport, so a new built-in pack is reserved the moment it ships. - Message:
"${id}" is reserved for a pack built into DiceyTable (diceytable.dining-table). Every client loads that id from the app itself, so a mod published under it could never load. Choose a different id.
Why it is refused rather than allowed to shadow. A game assigns a built-in table with an
ordinary pin, and every client resolves that pin from its own build before it asks the registry.
A mod registered under the same id would therefore never be the table that loads — but its
registry card, credits and picker entry would say it was. Refusing the id keeps "the pack named
diceytable.dining-table" meaning exactly one thing.
Path & asset rules (8)#
unsafe-path#
- Severity: error
- Where:
validateManifestPaths—packages/shared/src/modManifest.ts. Runs during GitHub scan (scanGitHubMod,githubScanner.ts) and local draft validation. - Condition: any of
manifest.assets[],entry.setup, orentry.scriptstarts with/, contains.., contains a backslash, or matches^https?://(modManifest.ts). - Message:
Mod paths must be relative repository paths without traversal or external URLs.
unsupported-asset-type (manifest)#
- Severity: error
- Where:
validateManifestPaths—packages/shared/src/modManifest.ts. GitHub scan + local draft validation. - Condition: an asset path in
manifest.assets[]has an extension outsideMOD_ASSET_ALLOWED_EXTENSIONS(19 extensions, verified by count from source —modManifest.ts:.json .png .jpg .jpeg .webp .gif .bmp .avif .ktx2 .basis .glb .gltf .bin .mp3 .ogg .wav .txt .csv .hdr). - Message:
Unsupported asset type: ${extension || "none"}.(extension included verbatim, e.g.Unsupported asset type: .psd.) - Note: the scanner's content-type policy (
ASSET_CONTENT_TYPE_ALLOWLIST) covers the same 19 extensions and is typed asRecord<ModAssetExtension, …>, so those two cannot drift without a compile error. The upload gate below is one narrower.UPLOADABLE_ASSET_EXTENSIONSis derived from the same tuple with.binremoved, so it holds 18 —.binis legal in a hand-authored manifest beside a.gltf, and is refused as a direct upload because no editor flow produces one.githubScanner.test.tspins the difference at exactly[".bin"].
unsupported-script-type#
- Severity: error
- Where:
validateManifestPaths—packages/shared/src/modManifest.ts. GitHub scan + local draft validation. - Condition:
manifest.entry.scriptis set and does not end in.js. - Message:
Sandboxed mod scripts must be JavaScript files.
missing-asset#
- Severity: error
- Where:
validateAsset—apps/server/src/githubScanner.ts. GitHub scan only — this does a liveHEADrequest toraw.githubusercontent.com, so it cannot run against a local draft. - Condition: the
HEADrequest for a declared asset (or an undeclaredcoverImage/screenshots[].path) gets a definite non-2xxanswer, such as404. A rate limit (401,403,408,429), a5xx, a network failure or a timeout is not recorded asmissing-asset: registration answers a retryable503 github-unavailableand leaves the existing record untouched. The same applies to plugin resource files. - Message:
Asset could not be fetched: ${response.status} ${response.statusText}.(dynamic — reflects the real GitHub HTTP status).
asset-too-large#
- Severity: error
- Where:
validateAsset—apps/server/src/githubScanner.ts(checked twice: once from theHEADresponse'scontent-length, once from the actual downloaded byte length). GitHub scan only. - Condition: the asset is larger than
MAX_ASSET_BYTES= 50 MB (githubScanner.ts) — or, for a.hdrsky, larger thanMAX_HDR_ASSET_BYTES= 10 MB (packages/shared/src/assetContentTypes.ts). Both are checked from the header first, so an oversize file is never downloaded. - Message:
Assets must be 50 MB or smaller in v1.— for a.hdr:HDR skies must be 10 MB or smaller — re-import it in the editor, which downscales it to fit. - Fix (
.hdr): import the sky through the editor's Upload. It re-encodes an oversize.hdr(and converts an.exr) and halves its resolution until it fits.
asset-content-mismatch#
- Severity: error
- Where:
fetchCheckedGitHubAsset—apps/server/src/githubScanner.ts. GitHub scan (mods and plugins) only. - Condition: a
.hdrasset's bytes do not begin with a Radiance header line (#?RADIANCEor#?RGBE)..hdraccepts generic content types (application/octet-stream,text/plain) because GitHub has none registered for it, so the bytes are what prove the file is a sky. - Message:
A .hdr asset must be a Radiance RGBE image (starting with #?RADIANCE or #?RGBE).
asset-content-type-missing#
- Severity: error
- Where:
validateAssetContentType—apps/server/src/githubScanner.ts. GitHub scan (viavalidateAsset) and the asset upload gate (viastore.ts, which re-runs it against the browser-suppliedcontentType). - Condition: the extension has a known content-type policy in
ASSET_CONTENT_TYPE_ALLOWLISTbut nocontent-typeheader (or an empty one) was returned/supplied. - Message:
Asset is missing a content-type header.
asset-content-type-mismatch#
- Severity: error
- Where:
validateAssetContentType— same function and call sites as above. - Condition: the extension's policy set does not contain the normalized
content-type, and thecontent-typeis not one of the two generic binary fallbacks (application/octet-stream,binary/octet-stream,githubScanner.ts). - Message:
Asset content-type ${normalized} does not match expected policy for ${extension}.
asset-read-failed#
- Severity: error
- Where:
validateAsset—apps/server/src/githubScanner.ts. GitHub scan only. - Condition: the
HEADrequest succeeded (somissing-assetdid not fire) but the follow-upGETused to hash the asset body got a definite non-2xxanswer. A transient failure answers503 github-unavailableinstead, as formissing-asset. - Message:
Asset content could not be read for hashing: ${bodyResponse.status} ${bodyResponse.statusText}.
Media rules (2)#
Files under the reserved media/ folder (cover image, screenshots) get their own,
additive checks on top of everything above. GitHub scan only — see
Required & Conventional Files for what
lives in media/.
media-unsupported-type#
- Severity: error
- Where:
validateMediaImageAsset—apps/server/src/githubScanner.ts. - Condition: a path under
media/(perisMediaPath) has an extension outside.webp,.png,.jpg,.jpeg(MEDIA_IMAGE_EXTENSIONS,githubScanner.ts). - Message:
Files under media/ must be images (.jpeg, .jpg, .png, .webp); got ${extension || "no extension"}.(the list is[...MEDIA_IMAGE_EXTENSIONS].sort()— alphabetical, not declaration order).
media-path-invalid#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. - Condition:
manifest.coverImageor amanifest.screenshots[].pathis not a safe relative path (isSafeRelativeModPath) or is not undermedia/(isMediaPath). - Message:
coverImage and screenshots must be relative paths under the media/ folder.
Capability rules (3)#
undeclared-capability#
- Severity: error
- Where:
validateManifestCapabilities—packages/shared/src/modManifest.ts. Runs during GitHub scan/re-scan and local draft validation, against the entry script's compiled/source text. - Condition: the script's source matches one of the capability detector regexes (
scriptCapabilityDetectors,modManifest.ts— 13 regexes for the 12 capabilities, becauseread-worldhas a second detector that fires on anonZone*/onTrigger*hook subscription) whose capability is not present inmanifest.capabilities.allowed. - Message:
Script uses capability "${capability}" but it is not declared in manifest.capabilities.allowed. - The asymmetry: only using an undeclared one rejects the mod. Declaring a capability you never use no longer passes in silence, though — it is a warning (
scene-script-capability-unused, orcomponent-pack-capability-unusedfor an Asset pack), because an unused entry over-warns every player who reads your declaration. Full detector-by-detector detail: Capabilities Reference. - The one worth knowing by name:
/\bapi\.getUnredactedSnapshot\s*\(/demandsread-hidden-information. The elevated read was given its own method name rather than a flag onapi.getSnapshot()precisely so this detector could exist — the scanner reads script text, so "this mod reads hidden information" has to be visible in the script, greppable by a reviewer, not only in the manifest. - The plugin half: a
manifest.pluginsentry naming at least one function, withoutplugin-callincapabilities.allowed, is reported under this same code, with the messagemanifest.plugins declares plugin functions, so capabilities.allowed must include "plugin-call".A resource-only entry ("functions": []) grants no call and does not trigger it.
dynamic-plugin-call#
- Severity: error
- Where:
validateManifestPluginUses—packages/shared/src/modManifest.ts, folded intovalidateManifestCapabilities, so it runs on every path that one does: GitHub scan, serve-time re-scan, and local draft validation. - Condition: the script contains an
api.callPlugin(whose first two arguments are not both plain string literals. A variable, a computed name, or a template literal carrying an interpolation all count — the scanner counts every call site and separately reads the ones it can resolve, and a site it could not read is a rejection rather than a shrug. - Message:
api.callPlugin() must name its plugin id and function as plain string literals so the scanner can see which plugin functions this mod calls. - Why there is no exemption: the point of the declaration is that a reviewer can see a mod's entire external reach from its manifest without running anything. A computed target defeats that in both directions — the manifest would describe a reach the script does not have, or the script would have one the manifest does not describe. Branching between two literal call sites is one extra line.
undeclared-plugin-call#
- Severity: error
- Where: the same function, the same three paths.
- Condition: a statically-readable
api.callPlugin("<id>", "<fn>"pair thatmanifest.pluginsdoes not declare. - Message:
Script calls plugin function "<id>.<fn>" but manifest.plugins does not declare it. - Also enforced at run time: an undeclared pair resolves to
{ ok: false, reason: "not-found" }, indistinguishable from a plugin that is not installed. This rule is the half that makes it visible before anybody installs the mod. Full rules: Calling a plugin from a mod.
JSON & schema rules (13)#
invalid-json#
- Severity: error
- Where: two call sites, same code, different fallback text.
validateJsonFile(apps/server/src/githubScanner.ts) — GitHub scan, forentry.setupwhen it parses as JSON but is neither a validedit-scenenor a validmod-setupdocument (or is not valid JSON at all, or is really missing). If GitHub does not answer while fetching it (rate limit,5xx, network failure, timeout), this is not recorded: registration answers a retryable503 github-unavailableand the existing record is untouched. The same is true for the entry script and the manifest.parseJsonDocument(packages/shared/src/modManifest.ts) — local draft validation, for the manifest text and the setup text.
- Condition:
JSON.parsethrows, or (scan path only) the parsed document fails botheditSceneSnapshotSchemaandmodSetupSchema. - Message: the real parse/Zod error message, or a fallback —
"Setup JSON could not be parsed."(scan path) vs."JSON could not be parsed."(local-draft path). The fallback text itself differs between the two call sites; this is a genuine, if harmless, small inconsistency — see escalations.
invalid-model-meta#
- Severity: error
- Where:
validateSidecarJsonAsset—apps/server/src/githubScanner.ts. GitHub scan only (runs insidevalidateAsset, so it needs the fetched bytes). - Condition: the asset path matches
isModelMetaPath(a<model>.meta.jsonsidecar) and its content is not valid JSON, or does not matchmodelAssetMetaSchema. - Message: two variants under the same code —
Model meta sidecar is not valid JSON: ${error.message}.orModel meta sidecar does not match the expected schema (${field}: ${zodMessage}).
model-meta-authoring-ignored#
- Severity: warning — the model still loads.
- Where:
validateSidecarJsonAsset— the same function, theisModelMetaPathbranch. GitHub scan only. - Condition: a
<model>.meta.jsonsidecar is valid JSON and failsmodelAssetMetaSchema, and every Zod issue is confined to the two model-editor authoring keys,colliderandtriggers(MODEL_META_AUTHORING_KEYS). A root-level failure — the file is not an object at all — is not confined, and stays the error above. - Message:
Model meta sidecar has an invalid `collider`/`triggers` block (${field}: ${zodMessage}). It will be IGNORED at runtime — the model still loads with an automatic collider and no trigger volumes. Re-save it in the model editor to fix. - Why this is a warning and not an error. Extending
modelAssetMetaSchemawithcollider/triggerschanged this validator with no diff of its own: keys that used to be unknown and silently stripped became schema failures, and every other schema failure here makes the modincompatible. Refusing a mod outright for a block the runtime simply ignores would have taken published mods down for a cosmetic authoring defect.
invalid-deck-definition#
- Severity: error
- Where: same function as above,
isDeckDefinitionPathbranch (assets/decks/<slug>.deck.json). - Condition: not valid JSON, or does not match
customDeckDefinitionSchema. - Message:
Custom deck definition is not valid JSON: ${error.message}.orCustom deck definition does not match the expected schema (${field}: ${zodMessage}).
engine-incompatible#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition:
isCompatibleEngineRange(manifest.compatibility.engine, "0.1.0")returnsfalse. - Message:
Mod targets ${manifest.compatibility.engine}; current engine is 0.1.0. - Read this with a grain of salt:
compatibility.engineis not semver — see Manifest — Compatibility for the actual 2-token grammar, and a malformed range like"^0.1"matches everything rather than rejecting (isCompatibleEngineRange,packages/shared/src/modManifest.ts, falls through toreturn truewhen neither the>=nor the<regex matches).
missing-manifest#
- Severity: error
- Where:
validateLocalModProjectDraft—packages/shared/src/modManifest.ts. Local draft validation only — a GitHub scan can't reach this state becausescanGitHubModalready threw fetching the manifest. - Condition: the draft's manifest text is empty.
- Message:
Draft is missing diceytable.mod.json contents.
missing-local-asset#
- Severity: error
- Where:
validateLocalModProjectDraft—modManifest.ts. Local draft validation only. - Condition: an asset the manifest declares is not present among the draft's known files/asset paths.
- Message:
Manifest references an asset that is not present in the local draft.
missing-local-setup#
- Severity: error
- Where:
validateLocalModProjectDraft—modManifest.ts. Local draft validation only. - Condition:
manifest.entry.setupis set but not present in the draft's file list. - Message:
Manifest entry.setup does not exist in the local draft file list.
missing-local-script#
- Severity: error
- Where:
validateLocalModProjectDraft—modManifest.ts. Local draft validation only. - Condition:
manifest.entry.scriptis set but not present in the draft's file list. - Message:
Manifest entry.script does not exist in the local draft file list.
template-instantiation-failed#
- Severity: error
- Where:
validateLocalModProjectDraft—modManifest.ts(two call sites, same code/message shape). Local draft validation. (The same code is also produced by the separate, legacyPOST /api/mods/validate/setuproute —apps/server/src/modValidation.ts— which validates the oldmod-setupformat only and is out of scope for this reference; see Setup JSON.) - Condition: the
mod-setupdocument parses against its schema butinstantiateSetupObjectsthrows while expanding templates. - Message: the thrown error's message, or
"Unable to instantiate setup templates."as a fallback.
unsafe-project-path#
- Severity: error
- Where:
validateProjectFilePaths—modManifest.ts, called fromvalidateLocalModProjectDraft. Local draft validation only — this checks the draft's own file-metadata list, which a GitHub scan never sees. - Condition: a draft file's
pathfailsisSafeRelativeModPath. - Message:
Project files must use forward-slash relative paths without traversal or external URLs.
duplicate-project-path#
- Severity: error
- Where:
validateProjectFilePaths— same function. Local draft validation only. - Condition: the same
pathappears more than once in the draft's file-metadata list. - Message:
Project contains duplicate file metadata for ${file.path}.
schema-* (dynamic family)#
- Severity: error
- Where:
toCompatibilityIssue—packages/shared/src/modManifest.ts. Local draft validation only, for a manifest oredit-scenesetup document that fails Zod validation. - Condition: any Zod issue produced while parsing the manifest or the setup document with
safeParse. - Code:
schema-${zodIssue.code}— e.g.schema-invalid_type,schema-too_small,schema-custom. This is a family, not one fixed string; counted here as one rule. - Message: the raw Zod issue message, verbatim.
Per-type and setup-option rules (3)#
manifest.setupOptions declares a small typed form the host answers in the lobby, before the table
exists. Everything about one option in isolation — the key format, the length caps, the 12-option
and 32-choice ceilings — and every cross-field rule — duplicate keys, a default outside its own
bounds or absent from its own choice list — is decided by modManifestSchema while the manifest is
being parsed, which is a hard 400 at registration and a schema-* issue in the editor. See
manifest schema failure.
The two rules below are the ones a single option cannot decide, because they need the rest of the
manifest. Both run in scanGitHubMod (apps/server/src/githubScanner.ts) before the early-return
gate, so a mod refused for either fetches no assets at all.
setup-options-unreadable#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition:
manifest.setupOptionsis non-empty, and either the pack is not agame-packormanifest.entry.scriptis absent. - Message (a game pack with no script):
Declare "entry.script", or remove "setupOptions". Setup options reach a mod only as the third argument to setup(api, manifest, options), and this mod has no script. It declares ${count} (first: "${key}"), which nothing can ever read. - Message (any other pack type):
Remove "setupOptions", or publish this as a game pack. Setup options are answered in the lobby for the room's GAME and reach it as the third argument to setup(api, manifest, options). Only a game pack is ever that mod, so these would be a form nothing can read. It declares ${count} (first: "${key}"), which nothing can ever read.
Why a refusal rather than silence. Setup options arrive as the third argument to setup and by
no other route — there is no capability for them, no API method and no hook. A mod whose entry is
a setup.json alone has no code at all: instantiating a setup file places the entities it names and
consults nothing else. So an option declared there is asked of a host, answered by them, and then
read by nobody — the host configures something that does nothing, and nothing anywhere says so.
That is the failure this rule exists to replace, and it is the same argument CAP-1 makes for
refusing a scriptless pack's entry script by name instead of stripping it.
Two shapes trip it, and the difference is which advice is honest. A game pack with no script is
told to add one. Anything else is told to remove the options: the type refusal outranks the script
one, because the lobby resolves setup options from the mod it calls the room's game
(resolveRoomGameModId — the provider, or the single selected mod). A room, table or asset pack is
a dependency of a game rather than the game, so "add a script" would be advice that changes
nothing — and for a room or table pack the schema refuses the script outright (CAP-1) on top of that.
Only the option key is quoted back to you — bounded to 64 characters and to lowercase
snake_case by the schema. Your label and help are never interpolated into a refusal: two of them
at their maximum lengths would exhaust the 400-character message cap on their own, and the message
would be dropped rather than shown.
lobby-requirements-unread#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition:
manifest.lobbyRequirementsis non-empty andmanifest.typeis notgame-pack. - Message:
Remove "lobbyRequirements", or publish this as a game pack. Lobby requirements gate Start on what each seated player has chosen for the room's GAME, resolved from the room's provider mod. Only a game pack is ever that mod, so these would gate nothing. It declares ${count}, which nothing will ever ask. path:diceytable.mod.json:lobbyRequirements
The counterpart to the rule above, one axis over. Scriptlessness is not what decides this: a
game pack with no entry.script may declare requirements perfectly well, because the platform asks,
checks and applies them without any code of yours. What decides it is being the room's game.
Both setupOptions and lobbyRequirements are read from the single mod resolveRoomGameModId
resolves, and a room, table or asset pack is never it. A requirement declared on one is asked of
nobody — the pack publishes, the lobby never shows the picker, and nothing anywhere says why.
It is refused at publish and not at parse, deliberately. modManifestSchema still accepts the
field on any type, so a pack already published carrying it stays readable rather than disappearing
from the tables that depend on it. Publish is the moment an author is present to be told. The whole
per-type table, and the rule for which tier a field belongs in, is in
packages/shared/src/modTypeFields.ts.
The editor does not offer it. The manifest editor renders the Lobby requirements section for a game pack only, so the ordinary way to reach this refusal is a hand-written manifest — and if one arrives that way, the editor lists the key with its reason and a Remove button rather than hiding it along with the section.
rules-unread#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition:
manifest.rulesis non-empty andmanifest.typeis notgame-pack. - Message:
Remove "rules", or publish this as a game pack. Rules are opened from the room's GAME (the Rules button resolves the room's game mod) and shown on a game's public page. Only a game pack is ever that mod, so these would be a rulebook nothing can open. Publish them on the game pack that uses this one. It declares ${count}, which nothing will ever open. path:diceytable.mod.json:rules
The same argument as the two rules above, with a cost they do not have. Rules are read from the
single mod resolveRoomGameModId resolves — the room's provider, or the one selected mod — and a
room, table or asset pack is a dependency of a game rather than the game. A rulebook declared on
one is opened by nobody. Unlike an unread setup option, though, it is not merely inert: an author
who uploads a 40-page rulebook to a table pack has pushed megabytes to their repository that no
surface will ever show them. That is why this is a publish refusal and not an advisory.
Refused at publish rather than at parse, for the reason every publish-tier row records: a manifest
already published carrying the field stays readable rather than disappearing from the tables that
depend on it. The editor does not render the Rules section for the other three types.
dice-unread#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition:
manifest.typeis notgame-pack, and eithermanifest.diceis non-empty ormanifest.diceEnabledisfalse. Each of the two is reported separately. - Message (list):
Remove "dice", or publish this as a game pack. The dice picker offers the dice declared by the room's GAME, resolved from the room's provider mod. Only a game pack is ever that mod, so this list would be offered to nobody. Declare it on the game pack that uses this one. It declares ${count}, which nothing will ever offer. - Message (switch):
Remove "diceEnabled", or publish this as a game pack.followed by the same explanation, without the count. path:diceytable.mod.json:diceordiceytable.mod.json:diceEnabled
The same argument as rules-unread, one field along. The dice picker offers the dice declared by the
single mod resolveRoomGameModId resolves — the room's provider, or the one selected mod — and a room, table
or asset pack is a dependency of a game rather than the game. A dice list on one is offered to nobody.
A dice list is cheap, so this is not the bandwidth argument rules-unread makes: it is the invisibility one.
The picker does not fall back to nothing, it falls back to the standard d4–d20 set, so an author who curated
dice on a table pack sees a working picker showing the wrong dice, with nothing anywhere to say why the ones
they chose never appeared. Refused at publish, where they are still around to move the list to the game pack.
Refused at publish rather than at parse, for the reason every publish-tier row records: a manifest already
published carrying the field stays readable rather than disappearing from the tables that depend on it.
rules-path-invalid#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition: a file named by a rules document is not a safe relative path under
rules/. - Message:
Move this file under the rules/ folder. Every rules file must be a relative repo path there, which is the folder both the scanner and the CDN scope rules file types to. path:diceytable.mod.json:rules[${index}]
rules/ is not a naming convention — it is the scope of a permission. .md and .pdf are
deliberately not mod asset types: you cannot declare one in assets, and the editor refuses one as
an upload anywhere else in the tree. They are readable only under rules/, and that scoping is what
lets a rulebook be published without widening what a mod may ship generally. A rules file named
outside the folder would be a file the CDN then refuses to serve, so it is refused here instead,
where the message can say what to do.
Like the two rules above this one runs before the early-return gate, so a malformed rules block
fetches nothing at all — the same amplification argument setup-options-unreadable makes.
rules-unsupported-type#
- Severity: error
- Where:
scanGitHubModandvalidateRulesFileAsset—apps/server/src/githubScanner.ts. - Condition: a rules file's extension is not one of
.md,.webp,.pdf. - Message:
Rules files must be one of .md, .webp, .pdf. A PDF is published as rendered .webp pages plus the original; the editor does that conversion for you.
A PDF is published twice, and only one of them is read. The editor renders each page to a
web-sized .webp and it is those images the Rules panel shows — no PDF reader is involved on the
reading side, on any surface, which is also why a rulebook works in the installed app. The original
.pdf rides along only as a Download the original PDF link. So a rules folder holds images and
markdown, plus at most one PDF per document, and anything else is a file nothing would ever open.
rules-too-large#
- Severity: error
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts. GitHub scan only. - Condition: one rules document's files — every page plus the preserved original — exceed 10 MB, either by the sizes the manifest declares or by the bytes actually in the repository.
- Message:
Shrink this rulebook to 10.0 MB or less, or Upload it to a file host (Google Drive, Dropbox, your own site) and add it as a Link instead. It declares ${size}.(the measured variant endsIts files measure ${size}.) path:diceytable.mod.json:rules[${index}]
Checked twice, on purpose. The declared sizes are checked before the network so an obviously oversize block costs no outbound fetches; the measured sizes are checked after, because a hand-edited manifest can claim whatever it likes and only the bytes settle it. The editor enforces a 20 MB / 120-page ceiling on the upload itself, which is a courtesy — this is the rule.
The way out is named in the message. A rulebook too big to publish is not a rulebook you cannot
ship: upload it to a file host and declare it as a link document. The Rules panel shows the
destination host and lets the reader follow it, and nothing is downloaded from us at all.
manifest-secret#
- Severity: error
- Where:
scanPluginManifestForSecrets(packages/shared/src/pluginManifest.ts), called fromscanGitHubModovermanifest.setupOptions. GitHub scan only. The same function is the plugin registry's own credential tripwire — one implementation, two callers. - Condition: any string inside
setupOptions— adefault, aselectchoicevalueorlabel, ahelpline — matches a credential shape: a GitHub token (ghp_…,github_pat_…), a Stripe-stylesk-live…/sk-test…key, a Slackxox…token, an AWS key id (AKIA…), a JWT, or aBearer …header value. - Message:
${trail} looks like an API credential. Plugin source is public and sha-pinned — configure credentials server-side instead.— wheretraillocates the offending string, e.g.setupOptions[0].options[1].value. path:diceytable.mod.json:setupOptions
Why this is checked at all. A manifest is public and, once registered, pinned to an immutable
commit — a key committed there is a published key, and re-pointing the pin does not unpublish it. A
select whose choices name deck sources is exactly the place an author reaches for an API key
without thinking about where the file lives.
⚠ The message says "Plugin source" because the wording belongs to the shared tripwire that the plugin registry also calls. Read it as manifest source: the sentence is true of a mod manifest for precisely the same reasons.
Pack content refusals — CAP-1 (4)#
A room pack describes a room: a lit box with decor. A table pack describes a table: a play surface with metrics. Neither is a game, and neither may carry executable code or describe the other's subject. Both halves are refused by name rather than stripped, because a silently dropped key is indistinguishable from one that was honoured — you would lay out seats in a room pack, see nothing, and have no way to tell "ignored on purpose" from "broken".
Why scripts specifically, and why this fails closed. Capability disclosure used to be derived
from the room's own mod list alone (room.selectedModIds, roomCapabilities.ts), which excludes
transitive dependencies — so a script sitting inside a pack you depend on ran with none of the
capability disclosures firing: no consent prompt, no pre-join gate entry, no in-room indicator, no
registry badge. The disclosure now unions the capabilities of every pack in a mod's resolved
dependency graph, read from each pinned pack's own published manifest, and a dependency it cannot
resolve is disclosed as elevated rather than as harmless.
That does not retire this rule, and the reason is worth stating: the union discloses what a pack
declares, and a room-pack or table-pack may declare nothing beyond log. A script smuggled
into one would therefore run while the room described it as log-only — under-disclosed rather than
undisclosed, which is no better to the player whose hand it read. "A pack cannot carry a script at
all" remains the protection, which is why this is an error (the mod lands incompatible) and not
a warning.
Presence is the test — not shape, not validity, not content. The check reads the raw JSON,
before any schema touches it, and fires on the presence of a top-level roomPack / tablePack
key. It does not require the document to be a well-formed room or table pack, and it does not
require the document to be scene-shaped. That is the whole point of the rule: a hand-written
{"roomPack": …, "scripts": […]} carrying neither schemaVersion nor environment used to fall
through to modSetupSchema, whose fields all have defaults and which therefore stripped both
unknown keys and reported nothing — and the document published compatible. An empty
"scripts": [] beside roomPack is still a refusal, for the same reason: Zod can only ever make a
document look cleaner than it is.
One issue is emitted per offending key, so a document carrying both scripts and
sceneScriptIds produces two.
room-pack-scripts-forbidden#
- Severity: error
- Where:
roomPackForbiddenKeyIssues—apps/server/src/githubScanner.ts, called fromvalidateJsonFileon both of its branches. GitHub scan only — it is not part of the serve-time re-scan and not part of local draft validation. - Condition: the raw
entry.setupJSON has a top-levelroomPackkey and ascriptsorsceneScriptIdskey (either one, defined, of any value). - Message:
A room pack may not carry scripts. Capability disclosure is derived from the room's own mod list, which excludes transitive dependencies, so a script in a depended-on pack would run with no disclosure at all (CAP-1). Put scripts in the game pack. - Also refused one level up: a manifest whose
typeisroom-packortable-packmay not declareentry.scriptat all, and may not declare any capability exceptlog— both are refusals insidemodManifestSchemaitself (packages/shared/src/modManifest.ts,SCRIPTLESS_MOD_TYPES), which means they arrive as a manifest schema failure rather than under a code of their own. That closes the other door: this rule stops a script arriving as scene data, that one stops it arriving as an entry point.
room-pack-forbidden-key#
- Severity: error
- Where: the same function, the same call sites.
- Condition: a document with a top-level
roomPackkey carries any of the other keys inROOM_PACK_FORBIDDEN_KEYS(packages/shared/src/packPayload.ts) —prefabs,zones,seatZones,seatTemplate,snapPoints,tablePack,tableMetrics,packRefs. - Message: one per key, naming why. Layout keys read like
A room pack describes a room. Player zones belong to the game pack that uses it.The three single-writer keys read longer: the table owns the play-surface height and footprint, a room may only read them, andpackRefsis refused because only a game assigns a room and a table — a pack that assigned a table would be a second writer forsurfaceY, and one that assigned a room would leave composition with two rooms and no rule for choosing. - Why two codes and not one: a CAP-1 violation is a security finding and has to stay greppable apart from "you put seats in a room pack".
table-pack-scripts-forbidden#
- Severity: error
- Where:
tablePackForbiddenKeyIssues—apps/server/src/githubScanner.ts, called fromvalidateJsonFileon both of its branches. GitHub scan only — it is not part of the serve-time re-scan and not part of local draft validation. - Condition: the raw
entry.setupJSON has a top-leveltablePackkey and ascriptsorsceneScriptIdskey (either one, defined, of any value). - Message:
A table pack may not carry scripts. Capability disclosure is derived from the room's own mod list, which excludes transitive dependencies, so a script in a depended-on pack would run with no disclosure at all (CAP-1). Put scripts in the game pack. - The asymmetry this page used to describe is closed. Until the transitive capability union
landed, this check ran only on documents that were not scene-shaped, justified by the fact
that a valid table pack is scene-shaped and its scripts are scanned there by
validateSceneScriptSafety. Scanning is not refusing: that pass judges a script's content, so a scene-shaped document carryingtablePackplus a script that passed the five content patterns published clean and was refused only at load. It now runs on both branches, exactly like the room's, so publish and load give the same verdict. - Also refused one level up: a manifest whose
typeistable-packmay not declareentry.scriptand may not declare any capability exceptlog— refusals insidemodManifestSchemaitself, which arrive as a manifest schema failure.
table-pack-forbidden-key#
- Severity: error
- Where: the same function, the same two call sites.
- Condition: a document with a top-level
tablePackkey carries any of the other keys inTABLE_PACK_FORBIDDEN_KEYS(packages/shared/src/packPayload.ts) —room,prefabs,zones,snapPoints,packRefs. Note the deliberate difference from the room's list:seatZonesandseatTemplateare kept, because the seat ring is a property of the table's own footprint. - Message: one per key, e.g.
A table pack describes a table. The room is owned by the game's assigned room pack; a table may not bring its own (O-19/O-30 — one writer per subject).
Component pack rules — CAP-1 (5)#
A component pack is the inverse of the two above. A room pack and a table pack are refused outright for carrying a script at all; a component pack is the pack kind whose whole point is prefabs with scripts, so nothing here can be refused for existing. What is refused is a pack whose declaration does not describe its code.
That declaration is not paperwork. Every room that depends on your pack discloses the union of its
dependencies' declared capabilities to its players, read from each pinned pack's own published
manifest. Your capabilities.allowed is therefore the only thing standing between a prefab script
and a player who was never told about it — and it is read from the immutable version record, so
narrowing it after publish changes nothing for anyone already pinned to that version.
All five rules run in componentPackScanIssues / componentPackSetupIssues
(apps/server/src/githubScanner.ts), after every rule above it on this page. They fire only when
manifest.type is component-pack.
component-pack-capabilities-undeclared#
- Severity: error
- Where:
componentPackScanIssues— GitHub scan only. It cannot run at serve time: the stored manifest has already been through the schema, so "the author omitted the block" is no longer distinguishable from "the author wrotelog". - Condition: the pack ships executable code — an
entry.script, or at least one script in its setup document — and the rawdiceytable.mod.jsonhas nocapabilities.allowedarray, or has an empty one. - Message:
A component pack that ships executable code must declare capabilities.allowed explicitly in diceytable.mod.json. Omitting it is not the same as declaring ["log"]: the schema's default is published to every dependent room as though the author had asserted it (CAP-1), and this is the pack type whose prefab scripts actually run. - Writing
"allowed": ["log"]satisfies it. The rule is about writing the list, not about how short it is. A pack that ships no code at all is untouched.
component-pack-undeclared-capability#
- Severity: error
- Where:
componentPackSetupIssues— GitHub scan and the serve-time re-scan, because a rule enforced on one path and not its sibling is not enforced. - Condition: a script carried in the setup document uses a capability that is not in
manifest.capabilities.allowed, detected by the samedetectScriptCapabilitiespatterns the entry-script rule uses. - Message:
Prefab script "<name>" uses capability "<capability>" but it is not declared in manifest.capabilities.allowed. A component pack's declaration is what every dependent room discloses to its players (CAP-1), so an undeclared capability would run with no disclosure at all. - This is the setup-document sibling of
undeclared-capability, which has only ever coveredentry.script. A script carried inside the setup document had no capability check at all before this rule. Its own code, so the two stay greppable apart. One issue per undeclared capability per script, pathed atsetup.json#scripts/<name>.
component-pack-capability-unused#
- Severity: warning — it never blocks a publish.
- Where:
componentPackScanIssues— GitHub scan only. - Condition:
capabilities.allowednames a capability that no script the pack ships appears to use.logis exempt: it is the schema default and the floor every pack has. - Message:
This pack declares capability "<capability>" but no script it ships appears to use it. Every room that depends on this pack discloses the declaration to its players (CAP-1), so an unused entry over-warns them. Trim it, or ignore this if the call is one the static detector cannot see. - Why a warning and not a refusal. Over-declaring fails closed for the player — the room
discloses more than your pack can do, so nobody is under-warned. And capability detection is a
whole-word pattern match, not a type checker, so refusing a pack because the detector could not
see a call you genuinely make would refuse real work. The cost it is naming is real in the other
direction: an unused
read-hidden-informationputs a consent prompt in front of every player of every game that depends on you.
component-pack-script-not-compiled#
- Severity: warning
- Where:
componentPackSetupIssues— GitHub scan and the serve-time re-scan. - Condition: a script in the setup document has no
compiledbody. - Message:
Prefab script "<name>" has no compiled body. It will not run, and its capability use cannot be verified against this pack's declaration until it is saved from the code editor. - The component-pack sibling of
scene-script-not-compiled, and it says the extra thing that matters here: an uncompiled script is not capability-checked either, so your declaration has not been verified against it.
component-pack-script-compiled-scan#
- Severity: warning — it accompanies a refusal, it is never the refusal.
- Where:
componentPackSetupIssues— GitHub scan and the serve-time re-scan. - Condition: one of the 5 script-content patterns refused a script in the setup document.
- Message:
The safety scan reads the COMPILED body of a prefab script, and comments survive the TypeScript emit. It is a whole-word text match with no lexer, so a bare window, top, parent, fetch or eval in a comment or a string is refused exactly as a real call would be. If you did not write the call, reword the comment. - 🔴 This is the one that wastes an afternoon. The scan reads the compiled body — what
actually executes — and TypeScript keeps your comments in it. It has no lexer, on purpose, because
that is what catches
self["fetch"]-style lookups. So a comment reading "we never callfetchhere" is rejected for callingfetch. That behaviour is inherited unchanged fromvalidateSceneScriptSafety: a component pack's scripts are neither more nor less strictly scanned than any other scene script. This warning exists only so the refusal explains itself.
Two shapes, one verdict. A component pack document may or may not carry an environment key —
the schema defaults it — and validateJsonFile routes on schemaVersion + environment. So a pack
that omits it misses the scene branch entirely, and its script bodies would otherwise publish
unscanned. componentPackSetupIssues therefore content-scans them itself, suppressing by
(code, path) anything the scene branch already reported, so a document that is scene-shaped
reports each finding exactly once.
Scene-script capability rules (2)#
Every mod type except component-pack ships its scripts inside the scene (entry.setup), and
until these two rules landed nothing compared what those scripts use against what the manifest
declares. undeclared-capability has only ever read entry.script;
component-pack-undeclared-capability reads only an Asset
pack's prefab scripts. A game-pack — the type that actually ships a scene full of scripts — could
call api.getUnredactedSnapshot() under a manifest declaring ["log"], and the disclosure a player
read before joining would be a lie about code running on their table.
This is why an author should care rather than work around it. capabilities.allowed is the text
every player is shown before they load your mod: the pre-join gate, the consent prompt, the
in-room indicator, the registry badge. A capability your declaration omits does not get a quieter
prompt — it runs with no disclosure at all. Widening the rule to every mod type can newly refuse
an already-published game pack, and that migration cost was accepted deliberately rather than
softened: an undeclared capability is a hard error, and the mod stays incompatible until its
author republishes with a corrected list.
Scope, worth stating plainly: only scripts that can actually execute are checked. The scripts
read are the ones already parsed off the scene branch (schemaVersion + environment). A setup
document that is not scene-shaped goes to modSetupSchema instead — a z.object whose every field
has a default, which therefore strips a scripts key — so those bodies never reach the sandbox,
and refusing a mod for them would be refusing dead data. (component-pack is the one type whose
document may legitimately miss the scene gate and still run its scripts, which is exactly why
its own rules read the raw JSON and content-scan it
themselves.)
No new content scan. validateSceneScriptSafety already scanned these exact compiled bodies, so
the compiled-comment trap is inherited unchanged — neither
stricter nor looser. Nothing here re-runs or relaxes it.
scene-script-undeclared-capability#
- Severity: error
- Where:
sceneScriptCapabilityIssues—packages/shared/src/modManifest.ts, re-exported byapps/server/src/githubScanner.ts. GitHub scan and the serve-time re-scan, because a rule enforced on one path and not its sibling is not enforced. At registration it makes the modincompatible; at serve time the same function becomes the hard throw Scene scripts no longer declared. - Applies to: every
manifest.typeexceptcomponent-pack, which has its own, differently-coded rule — so one script never collects both codes. The two are kept distinct on purpose: an Asset pack's declaration is consumed by a third party's room through CAP-1's union, where nobody can read the pack's code; a game pack's declaration describes only itself. - Condition: a scene script's
compiledbody matches a capability detector (detectScriptCapabilities— the same patternsundeclared-capabilityuses) whose capability is not inmanifest.capabilities.allowed. One issue per undeclared capability per script, pathed at<entry.setup>#scripts/<script name>— e.g.setup.json#scripts/spy.ts. - Message:
Scene script "<name>" uses capability "<capability>" but it is not declared in manifest.capabilities.allowed. Add "<capability>" to capabilities.allowed in diceytable.mod.json and republish, or remove the call. capabilities.allowed is what every player of this mod is shown before they load it, so a capability the declaration omits would run on their table with no disclosure at all. - A script with no compiled body is skipped silently. It cannot run and it cannot be
capability-checked;
scene-script-not-compiledalready warns about it on the same path, and a second warning here would double-report one fact.
scene-script-capability-unused#
- Severity: warning — it never blocks a publish.
- Where:
declaredCapabilityUsageIssues—packages/shared/src/modManifest.ts, re-exported byapps/server/src/githubScanner.ts. GitHub scan only, pathed atdiceytable.mod.json(the declaration is the thing at fault, not any one script). - Applies to: every type except
component-pack, and only to a mod that carries code at all — anentry.script, or at least one compiled scene script. Usage is the union of the entry script and every scene script, so a capability used by the entry script alone counts as used. - Condition:
capabilities.allowednames a capability that nothing the mod ships appears to use.logis exempt: it is the schema default and the floor every mod has. plugin-callis also used by a lobby deck database. AlobbyRequirements[].deckDatabasemakes the platform call a declared plugin function for the member, with no script involved, so it counts as a use and no warning is raised.- Message:
This mod declares capability "<capability>" but no script it ships appears to use it. Every player is shown the declaration before loading, so an unused entry over-warns them. Trim it, or ignore this if the call is one the static detector cannot see. - Message when
plugin-callis unused butpluginsstill lists functions:This mod declares "plugin-call" and lists plugin functions, but no script calls a plugin and no lobby deckDatabase uses one, so every player is warned for nothing. To trim it, remove "plugin-call" AND empty each plugin's "functions" list — removing only the capability is refused. Ignore this if the detector cannot see your call.Removing only the capability would trip theundeclared-capabilityplugin rule, which refuses listed functions withoutplugin-call. A plugin kept only for its resources is{ "id": "…", "functions": [] }.
Why undeclared is an error but declared-but-unused is only a warning. The pair looks
inconsistent until you notice which way each one fails. Under-declaring fails open for the
player: code runs that nothing disclosed. Over-declaring fails closed: the mod claims more than
it can do, so nobody is under-warned — the real cost is only that an unused
read-hidden-information puts a consent prompt in front of every player for nothing. And
detectScriptCapabilities is a whole-word pattern match with no type checker, so making the
unused case an error would refuse legitimate work whose call the detector simply cannot see. That is
the same rationale already documented for
component-pack-capability-unused, one mod type over.
Room performance budget (3)#
A room's cost is checked at publish so a weak GPU is not where a heavy room is first discovered.
These are cost rules, not safety rules, and they are deliberately kept apart from the sandbox
checks: they run after them, they only add issues, and a change to a cost threshold can never reach a
security verdict. The same numbers are shown live in the Room Editor while you build, from the same
classifier (roomPackBudgetFindings, packages/shared/src/packPayload.ts), so a refusal here is
never the first time you see them.
The thresholds are measured, not guessed, and packages/shared/src/packPayload.ts records the
hardware and method behind each one. The most useful thing to know: light count is free. Going
from 7 to 24 room lights measured as no frame-cost change at all, so a 24-light room publishes and
lights has no refusal threshold. What costs is shadow views — a shadow-casting point light
renders six views of every caster (it is a cube map), a spot or directional renders one.
All three rules fire from roomPackBudgetIssues (apps/server/src/githubScanner.ts), called by
validateJsonFile during the GitHub scan. Scoping is by the presence of a top-level roomPack
key in the raw JSON — exactly like the CAP-1 pack refusals above, and for the same reason. It is
not limited to a scene-shaped or otherwise valid document: a setup.json that claims roomPack
and then fails to parse as one reports room-budget-unmeasurable rather than being skipped. A
plain game scene, which carries no roomPack key at all, is not budget-checked. The forbidden-key
checks above run first and stay a separate function, so a cost threshold can never reach a
security verdict.
room-budget-exceeded#
- Severity: error
- Condition: a measured cost is above its refusal threshold: more than 24 shadow views, more than 256 draw calls, more than 1,500,000 triangles, or more than 256 MB of encoded texture bytes (summed from the image assets the scan already fetched and sized).
- Message: the measured number, the limit, and the remedy — e.g.
144 shadow views exceeds the limit of 24. Shadow views, not light count, are what a room costs…
room-budget-warning#
- Severity: warning
- Condition: a measured cost is above the recommended value but below the refusal threshold — more than 8 shadow views, 96 draw calls, 250,000 triangles or 96 MB of texture bytes.
- Message: the measured number and the recommended value, naming the refusal threshold too.
room-budget-unmeasurable#
- Severity: warning
- Condition: the document carries
roomPackdata but does not validate as a room pack, so its cost could not be measured. - Message:
This document declares roomPack data but does not validate as a room pack, so its performance budget could not be measured.
Asset pack performance budget (3)#
The sibling of the room budget above, for a component pack (author-facing name: Asset pack),
and built on the same principle: cost rules, kept apart from the safety rules, running after them,
adding only issues, and never able to reach a security verdict. The same numbers are shown live in
the editor from the same classifier (componentPackBudgetFindings,
packages/shared/src/packPayload.ts).
The thresholds are measured, and one measurement is worth knowing before you optimise anything: a sprite sheet is not faster than one image per card. 300 cards cost 2,784 draw calls either way, and the per-card condition actually measured slightly ahead. A card's UV window lives on its material, so a sheet-based card already needs its own material and never batched. What does cost is the number of distinct card faces, because distinct faces are distinct materials — going from 1 to 300 of them across the same 300 cards cost about 1.2 ms per render on the reference GPU.
Two more results shape the rules. A deck is nearly free until it is dealt: a 300-card deck
object measured 6 draw calls, and 2,448 once the same cards were on the table. A pack cannot know
how much of a deck a game will deal, so deckCards warns and never refuses. And a sprite sheet
larger than the GPU's MAX_TEXTURE_SIZE never uploads at all — 32,768px raised a hard GL
INVALID_VALUE on the reference GPU, and every card in that deck would render with no art. That is
the one real cliff, and the one rule most likely to refuse you. The in-app deck builder caps itself
at 4,096px, so a deck it generated cannot trip it; a hand-written or imported .deck.json can.
All three rules fire from componentPackBudgetIssues (apps/server/src/githubScanner.ts), called
by validateJsonFile on both of its branches. Scoping is by the presence of a top-level
componentPack key in the raw JSON, exactly as for the room.
component-pack-budget-exceeded#
- Severity: error
- Condition: a measured cost is above its refusal threshold: a sprite sheet more than 8,192px on a side, more than 4,096 draw calls, more than 1,500,000 triangles, or more than 256 MB of texture bytes.
- Message: the measured number, the limit, and the remedy — e.g.
16384px largest sheet side exceeds the limit of 8192. A sprite sheet larger than the GPU's MAX_TEXTURE_SIZE never uploads… - Note on texture bytes: deck sheet bytes are decoded VRAM, computed from the sheet grid the document itself declares, so the editor and the scanner compute the same number. Encoded bytes for model textures the document does not describe are added on top; encoded is the smaller number for the same art, so publish errs lenient.
component-pack-budget-warning#
- Severity: warning
- Condition: a measured cost is above the recommended value but below any refusal threshold — more than 200 prefabs, 300 deck cards, 256 distinct card faces, a 4,096px sheet, 1,200 draw calls, 250,000 triangles or 96 MB of texture bytes.
- Message: the measured number and the recommended value.
- Three of those fields can only ever warn.
prefabs,deckCardsanddistinctCardFaceshave no refusal threshold at all, because the measurement did not find a cost worth refusing them for: a 300-card deck is 6 draw calls, and a 300-card TCG set is legitimate work.
component-pack-budget-unmeasurable#
- Severity: warning
- Condition: the document carries
componentPackdata but does not validate as an Asset pack, so its cost could not be measured. - Message:
This document declares componentPack data but does not validate as an Asset pack, so its performance budget could not be measured.
Dependency graph rules (7)#
A mod's manifest.dependencies pins other published packs, each to one exact version. Seven
rules check the resulting graph, and all seven need something no single document can see: the
registry. They therefore run at the fifth enforcement point — modDependencyIssues
(apps/server/src/githubScanner.ts), called by ModService.upsertScannedMod
(apps/server/src/services/modService.ts) with InMemoryStore.findPublishedPack as the lookup —
after the whole GitHub scan has already fetched and statically scanned the manifest, the entry
script and the setup document. Nothing in the security boundary is skipped or reordered to make
room for them; they only ever append errors.
The rules themselves live in resolveModDependencies (packages/shared/src/modManifest.ts), which
reports and never throws. The server function adds exactly one rule of its own
(dependency-commit-mismatch) and otherwise just translates. Every code is the shared
ModDependencyIssueCode prefixed with dependency-, so a dependency refusal is one search and can
never collide with a scanner code, and the issue's path field carries the pack id, not a file
path.
What an error here costs you, which is the point. An incompatible mod is not served
(fetchGitHubModArtifact serves compatible only) and gets no published version row, so it
cannot itself be depended on by anybody. The graph rules are enforced transitively rather than once
at the root: a pack can only appear in your graph if its own graph was clean when it published.
Two design decisions explain most of this list. Dependencies are capped at depth 2 and cycles are refused (O-24), because deep graphs are where dependency systems rot. And versions are named, immutable, and pinned to a commit (O-20) — semver ranges are deliberately rejected, not unimplemented. A range hands you back exactly the automatic breakage the model exists to prevent, and it makes a table non-reproducible: two peers who resolved the range at different times would compose different tables, and the resolved room/table is not replicated, so nothing would ever detect the disagreement.
What is refused earlier, and so never appears here. A range instead of an exact label, more
than 24 declared dependencies, a self-reference or a repeated pin in your own manifest, and a
room-pack/table-pack that declares an entry script or a capability are all refused by
modManifestSchema while the manifest is being parsed — before any of this runs. At registration
that is a manifest schema failure, a hard 400 carrying the
Zod message; in the editor the same rules produce a schema-* issue.
dependency-cycle#
- Severity: error
- Condition: a pack id reappears on its own resolution path —
a → b → a, or a depth-2 return to the root. The depth cap alone would already stop the walk; the cycle rule exists to make the graph illegal and say so, because a cycle silently truncated at depth 2 is a graph whose meaning changes the day the cap changes. - Message:
Dependency cycle: a -> b -> a. Cycles are refused (O-24) — a graph that reaches itself has no order in which its packs can be composed.
dependency-depth-exceeded#
- Severity: error
- Condition: a pack at depth 2 declares dependencies of its own. The cap bites there and only there, and the too-deep entries are refused by name rather than truncated — you are told the graph is too deep instead of watching a pack quietly fail to arrive.
- Message:
a -> b -> c is 2 levels deep. Transitive dependencies are capped at 2 (O-24). Depend on "c" directly, or vendor it.
dependency-unresolved#
- Severity: error
- Condition: the registry has nothing to hand back for this exact
(packId, version)pair. Three different facts collapse into this one code, because to a dependent they are the same fact — this pin does not name something that can be loaded:- No published version row. Only a published, pinned version is depend-on-able. A mod that was registered without a resolvable commit sha has no version row, and there is deliberately no fallback to the live record or to its branch.
- No live mod record. The mod was deleted; the pin points at nothing.
- The mod is
blocked. An administrator's takedown reaches dependents too, and a taken-down pack must not keep entering new dependency graphs.
- Matching is exact on both id and label — no case folding, no normalisation, no "did you mean".
- Message:
"some-pack" version 1.2.0 could not be resolved. A dependency that cannot be fetched is a table that will not load.
dependency-version-conflict#
- Severity: error
- Condition: the same pack is pinned to two different versions anywhere in the graph — most
often you pin
[email protected]directly while something you depend on pins[email protected]. - Message:
"a" is pinned to 1.0.0 by root -> a and to 2.0.0 by root -> b -> a. One pack, one version: two peers that resolved different ones would compose different tables and nothing would detect it. - The fix is a real decision, not a flag. There is no resolver, no "nearest wins" and no
latest: move your own pin to the version the other one wants, or ask the other pack to move.
dependency-self-dependency#
- Severity: error
- Condition: a pack declares itself as a dependency. A pack is composed after its dependencies, so a self-reference has no order in which it can be resolved.
- Message:
"a" cannot depend on itself. A pack is composed after its dependencies, so a self-reference has no order in which it can be resolved (O-24). - You are unlikely to see this code, because the same rule is applied to your own manifest at parse time and fails there first. It exists here as defence in depth, for a declaration found on a pack deeper in the graph.
dependency-duplicate-dependency#
- Severity: error
- Condition: one manifest lists the same pack id twice, in any casing. A pack may be pinned to exactly one version, or the pin does not determine what loads.
- Message:
"a" declares "b" more than once. A pack may be pinned to exactly one version, or the pin does not determine what loads (O-20). - Same caveat as above: your own manifest is refused at parse; this code covers a deeper one.
dependency-commit-mismatch#
- Severity: error
- Where: the one rule
modDependencyIssuesowns outright, rather than delegating toresolveModDependencies. - Condition: a pin supplies an optional
commitShaand that sha is not the one the named version was actually published at. Checked at depth 1 and 2 — a transitive pin is a pin. Pins that failed to resolve are skipped, since they already carrydependency-unresolved. - Message:
"a" version 1.0.0 was published at commit 0123456789ab, but this pin names abcdef012345. A published label is never repointed, so the two cannot both be right. Drop the commitSha and let the label resolve, or pin the version that really carries that commit. - Why it cannot be resolved in your favour: the label is the pin and the commit is the record of what it resolved to. When the two disagree, exactly one can be honoured, and choosing silently is precisely the failure O-20 exists to prevent — two peers could each honour a different half.
- Message length: dependency messages embed
a -> b -> cid chains, and ids run to 96 characters, so any message over 400 characters is truncated with a trailing...before it is stored. A truncated message is not a second failure.
Warnings (3)#
scene-script-not-compiled#
- Severity: warning — the original warning-severity rule in the scanner, since joined by
the two room-budget warnings, the two Asset-pack budget warnings, the two Asset-pack script warnings,
model-meta-authoring-ignoredandscene-script-capability-unused. A warning never flips a mod's status toincompatibleby itself. - Where:
validateSceneScriptSafety—packages/shared/src/modManifest.ts. Runs from all three script-relevant contexts: GitHub scan (validateJsonFile), GitHub re-scan (fetchGitHubModArtifact), and local draft validation (assignEditSceneSnapshot). - Condition: an
edit-scenesetup document has ascripts[]entry whosecompiledbody is missing (i.e. the script was saved but never re-transpiled from the code editor). - Message:
Scene script "${script.name}" has no compiled body and will not run until saved from the code editor.
unstated-model-collider#
- Severity: warning — the mod loads, and the runtime protects itself either way.
- Where:
sceneCheckFindings—packages/shared/src/sceneCheck.ts, called fromassignEditSceneSnapshotinpackages/shared/src/modManifest.ts, so it runs on the GitHub scan, the GitHub re-scan and local draft validation alike. - Condition: an entity carries
metadata.customModelAssetIdbut nophysics.collisionSize. - Message:
"${name}" has no saved collider, so until its model finishes downloading it is a ${x} x ${z} ft box — its scale, not its shape. Measure it to save the real collider. - What it actually means. A custom model's real collider cannot be known until its GLB has
downloaded and been measured, so until then the entity wears a placeholder box the size of its
scale. For a chess set authored atscale: 3that is a 3 ft cube per pawn, on a board whose squares are 0.174 ft apart. Saving a collider removes that window entirely. - Why this is a warning and not an error. The placeholder is sometimes right (a crate-shaped
model at
scale: 1), and the runtime does not trust it regardless — an entity whose collider is still a guess is kept out of the simulation rather than allowed to shove its neighbours. So this is the scanner telling you your scene is relying on that defence, not refusing it. - Fix: open the scene in the editor, select Entities, and use Scene Check — one button measures every model and saves its collider.
colliders-overlap#
- Severity: warning.
- Where: the same
sceneCheckFindingscall as above. - Condition: two unlocked entities whose effective colliders interpenetrate by more than 0.01 ft at their authored positions. Locked entities are excluded, because pieces standing on a locked board are meant to be inside its collider.
- Message:
"${a}" and "${b}" overlap by ${depth} ft. Physics will push them apart the moment the table runs, even though they look correct here — Edit Mode does not simulate. - Only the first 5 pairs are reported, followed by a line counting the rest. One shared mistake
produces a finding per pair — 528 of them for a 33-piece set — and a
CompatibilityIssuelist is capped at 500 rows. - Why you did not see it in the editor. Edit Mode does not run physics, deliberately: an author placing 32 pieces does not want them settling under gravity mid-edit. The cost is that overlap is invisible until a real table runs, which is why this check exists at all.
Hard throws (8)#
Unlike everything above, these are not CompatibilityIssue objects collected into a
list — they are JavaScript Errors thrown out of the scan/serve functions, which the
caller surfaces as a hard failure (a failed registration request, or a room that can't
load the mod) rather than a structured, path-bound issue.
Manifest failed schema validation#
- Where:
scanGitHubMod—apps/server/src/githubScanner.ts, themodManifestSchema.parse(...)that runs before any other check. GitHub scan only. - Condition:
diceytable.mod.jsondoes not satisfymodManifestSchema(packages/shared/src/modManifest.ts) — a missing or malformed field, or one of the refusals the schema makes on its own:- a
dependenciespin written as a range (^1.2.0,~1.2,1.x,>=1.0.0) instead of an exact label, or more than 24 pins; - a self-reference or the same pack id declared twice in your own
dependencies; - a
room-pack/table-packthat declaresentry.script, or that declares any capability other thanlog(SCRIPTLESS_MOD_TYPES, CAP-1).
- a
- Message: the raw Zod error, returned by
POST /api/mods/registeras{"error": "mod-scan-failed", "message": "…"}with HTTP 400. - Why you meet it as a throw and not as an issue: everything else on this page describes a mod
the scanner could read. A manifest that will not parse is not a mod yet, so there is nothing to
attach a path-bound issue to. The same rules produce structured
schema-*issues in the editor, where you are editing a draft rather than submitting one — that is the difference between the two spellings, not a difference in the rules.
Invalid repository URL#
- Where:
parseGitHubRepoUrl—apps/server/src/githubScanner.ts(twothrowsites, identical message). - Condition: the given
repoUrldoes not match^https:\/\/github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[/?#].*)?$. - Message:
Use a public GitHub repository URL such as https://github.com/owner/repo.
Upstream fetch failure#
- Where:
fetchText—apps/server/src/githubScanner.ts. Used for the manifest, the entry script, and every JSON setup fetch. - Condition: the
fetchtoraw.githubusercontent.comreturns a non-2xxresponse. - Message:
Unable to fetch ${url}: ${response.status} ${response.statusText}
Mod not compatible#
- Where:
fetchGitHubModArtifact—apps/server/src/githubScanner.ts. - Condition: the caller asks to load a
RegisteredModwhose storedstatusis not"compatible". - Message:
Only compatible mods can be loaded.
status is usually the scanner's own verdict, but it has one other writer: an administrator can set
it to blocked to take a published mod down. A block is stored separately from the scan result, so
re-registering the repo does not clear it, and it takes effect on the next load rather than
whenever a cache happens to expire. If a mod of yours is blocked, re-scanning will not help — get in
touch.
Scene scripts no longer safe (serve time)#
- Where:
fetchGitHubModArtifact—apps/server/src/githubScanner.ts. - Condition: the mod's
edit-scenesetup is re-parsed at serve time and at least one of its scripts failsvalidateSceneScriptSafetywith anerror-severity issue. - Message:
Mod scene scripts no longer pass sandbox compatibility checks. - This is the serve-time re-scan risk — see below.
Entry script no longer safe (serve time)#
- Where:
fetchGitHubModArtifact—apps/server/src/githubScanner.ts. - Condition: the mod's
entry.scriptis re-fetched and re-scanned at serve time and fails either the 5 script patterns orvalidateManifestCapabilitieswith anerror-severity issue. - Message:
Mod script no longer passes sandbox compatibility checks. - This is the serve-time re-scan risk — see below.
Prefab scripts no longer declared (serve time)#
- Where:
fetchGitHubModArtifact—apps/server/src/githubScanner.ts. - Condition: the mod is a
component-pack, its setup document is re-read at serve time, and a script it carries produces anerror-severity issue fromcomponentPackSetupIssues— an undeclared capability, or a banned content pattern in a document the scene branch does not cover. - Message:
Mod prefab scripts no longer pass component-pack capability checks. - Registered before this rule existed does not mean served past it, which is the same posture the two throws above take.
Scene scripts no longer declared (serve time)#
- Where:
fetchGitHubModArtifact—apps/server/src/githubScanner.ts, in theedit-scenebranch only (the one branch whose scripts actually execute). Runs after the content check above, so the content verdict is reached first and unchanged. - Condition: the mod is not a
component-pack, itsedit-scenesetup is re-parsed at serve time, and a scene script uses a capability thatmanifest.capabilities.alloweddoes not declare — i.e.scene-script-undeclared-capability, at serve time. - Message:
Mod scene scripts no longer pass manifest capability checks. - 🔴 The wording is one word away from the sandbox one. "…no longer pass manifest capability checks" means your script content is fine and your declaration is not. "…no longer pass sandbox compatibility checks" means a banned content pattern. The two throws are a few lines apart in the same branch and read almost identically, so check which one you got before you start editing code.
- A mod registered before this rule existed does not get to keep being served past it — the same posture the three throws above take.
Publish refusals — HTTP 409 (2)#
These two are not scanner codes and never appear in a mod's compatibilityIssues. They refuse
the POST /api/mods/register request itself, are thrown before anything in the registry is
written (so a refused publish leaves the previous record byte-identical rather than
half-applied), and come back as an HTTP 409 Conflict with a named error field that the editor
keys on. They are on this page because to an author they are the same experience as everything
above: a publish that does not go through, with a code to look up.
Both are refused before the version-row write, and mod-type-immutable is checked first on
purpose: a type change also changes the artifact and therefore the commit sha, so both refusals can
be true at once, and the less specific one must not shadow the more specific one.
mod-type-immutable (409)#
- Where:
InMemoryStore.upsertScannedMod(apps/server/src/store.ts) throwsModTypeImmutableError;POST /api/mods/register(apps/server/src/router/mods.ts) maps it to a 409 carryingpublishedTypeandattemptedType. - Condition: this mod id has already been published under a different
manifest.type. "Already published" reads the earliest version row's manifest first (append-only, so it still tells the truth), and falls back to the live registry record only when that record has actually been served — anincompatiblerecord is excluded, so correcting a mis-typed first attempt is not blocked. - Message:
This mod was first published as "room-pack" and cannot be re-published as "game-pack". A pack's type is immutable (O-26): dependents pin a version, not a type, so changing it would change what they load — and what capabilities they inherit — with no version bump and no disclosure. Publish the new kind under a new id. - Why it is a hard refusal. A dependency pin names
(packId, version)and deliberately does not record the depended-on pack's type — the registry's answer is the only answer. So aroom-packthat could become agame-packwould change what every dependent loads, with no version bump and no disclosure firing. - The fix: publish the new kind under a new mod id. There is no override.
mod-version-immutable (409)#
- Where:
InMemoryStore.upsertScannedModthrowsModVersionImmutableError; the same route maps it to a 409 carryingversionandpublishedCommitSha. - Condition: a published version row already exists for this
(id, manifest.version)at a different commit sha, and the incoming scan iscompatibleand has a resolved sha of its own. Re-scanning the same label at the same commit is a no-op, not an error — the registry re-scans routinely. - Message:
Mod "my-game" version 1.0.0 is already published at commit 0123456789ab and cannot be repointed at abcdef012345. Bump the version in the manifest and publish again. - Why (O-20): a published label is a name for a specific commit, forever. Repointing it would change what every dependent loads without any of them touching their pin — the same silent breakage that makes semver ranges refused everywhere else in this feature.
- The fix: bump
versionin the manifest and publish again. If you meant to publish the same bytes, you already have: nothing needed doing.
The editor upload gate (1)#
unsupported-asset-type (upload gate)#
- Severity: error
- Where:
validateUploadedAssetExtension—apps/server/src/githubScanner.ts. Fires fromProjectStore.uploadProjectAsset(apps/server/src/store.ts) — a direct file upload into a draft, not a GitHub scan. - Condition: the uploaded file's extension is not one of the 17 entries in
UPLOADABLE_ASSET_EXTENSIONS(githubScanner.ts) — the 18 manifest-legal extensions with.binremoved. A.bindeclared in a GitHub manifest is fine; a.binuploaded from the editor is refused here. - Message:
Unsupported asset type: <ext>. Allowed: .avif, .basis, .bmp, .csv, .gif, .glb, .gltf, .jpeg, .jpg, .json, .ktx2, .mp3, .ogg, .png, .txt, .wav, .webp.(alphabetically sorted, computed as[...UPLOADABLE_ASSET_EXTENSIONS].sort()) — note this message is not identical to the manifest-contextunsupported-asset-typeabove: it additionally lists every allowed extension. - How you actually see it:
uploadProjectAssetwraps this (and the content-type checks that follow it) into a thrownErrorrather than a structured issue:Asset upload failed for "${assetPath}" (${code}): ${message}.(store.ts,uploadProjectAsset). - Also enforced at upload time, but not one of the 68 rules on this page (ad hoc
store.tschecks, not fromgithubScanner.ts/modManifest.ts): an empty path, a path containing.., a model asset (.glb/.gltf) over 25 MB (MAX_UPLOADED_MODEL_BYTES,store.ts), or any asset over 50 MB (MAX_UPLOADED_ASSET_BYTES,store.ts).
The serve-time re-scan risk#
This is worth reading even if nothing is currently rejecting your mod. A mod is not
scanned once and then trusted forever. fetchGitHubModArtifact (githubScanner.ts)
re-fetches the entry script and the edit-scene setup every time a compatible mod is
loaded into a room, and re-runs the same script-safety and capability checks against
whatever is on the ref right now — and throws if they no longer pass (see the two
"no longer safe" hard throws above).
Concretely: your mod can pass registration today, sit untouched in your repo, and then fail to load next week — not because you changed anything, but because:
- a maintainer edits
SANDBOX_SCANNER_RULES/bannedScriptPatterns(tightens a pattern, adds a new one), or - your
refis a mutable branch name (not a resolved commit sha — see Publishing & GitHub onresolveCommitShabeing best-effort) and someone pushes a change to that branch that a human reviewer never ran through registration.
There is no notification for this today: the first sign is a room failing to load the
mod. If you want a published mod to be immune to future rule changes, pin ref to an
immutable commit sha rather than a branch.
A note on duplication (not a user-facing risk today)#
The 5 script-content patterns exist in two source locations —
SANDBOX_SCANNER_RULES (apps/server/src/githubScanner.ts) and
bannedScriptPatterns (packages/shared/src/modManifest.ts) — with no shared
constant between them. As verified for this page (2026-07-27), they are byte-identical
in effect: same 5 codes, same order, character-identical regex patterns and messages.
The only structural difference is a rationale field on the scanner's copy that nothing
reads. This page documents them as the one set they are today. The risk is future
drift if one copy is edited without the other — nothing currently enforces that they
stay in sync beyond a test asserting SANDBOX_SCANNER_RULES.length >= 5
(githubScanner.test.ts). See Script Safety
for the rules themselves.
See also#
- Script Safety — the 5 patterns in depth, with workarounds.
- Fixing a Rejection — symptom → cause → fix, by code.
- Capabilities Reference — the 12 capabilities and their detectors.
- Manifest Reference — every manifest field, including
assets,entry,compatibility. - Required & Conventional Files — the
media/folder and the rest of the file tree. - Surface B —
apireference — what each capability actually unlocks at runtime. - Scanner rule codes — the codes each rejection on this page is reported under.
manifest.dependencies— how a pin is written, and why there are no version ranges.- Assign a room or a table — what a room pack and a table pack are for, from the editor's side.
- Scene Check — the editor panel that reports and fixes the two collider warnings above before you publish.
