Patterns that come up again and again, ready to paste into a list's write rules. They're the same snippets the dashboard's Templates menu inserts, so you can start from one there and edit it in place.
Each recipe covers one thing, so most of them leave the other operations to your token permissions. Combine them: a rule set is just a list of statements, and you can paste several together.
Only an item's owner can change or delete it.
1// Only the item's owner can change or delete it2allow update, delete: identity != null && oldItem.identityId == identity.id;34require update "owner field is fixed": unchanged("/ownerId")5 else "ownerId can't be changed";
The starting point for most apps: an identity may change its own items and nobody else's. oldItem.identityId is who the item belongs to, which the client can't change, and the second rule stops the owner field inside the data being edited either.
Fields that can never change once an item is created.
1// These fields are set on create and never change2require update "immutable fields": unchanged("/createdBy", "/type")3 else "createdBy and type can't be changed";
JSON schema can't express this, because it only ever sees the new data. unchanged compares the old and the new by JSON pointer, and a field missing from both counts as unchanged.
Checks JSON schema can't express.
1require create, update "price": new.price >= 02 else "price must not be negative";34require update "paid is final": old.status != "paid"5 else "paid orders can't be changed";67require create, update "dates": timestamp(new.endsAt) > timestamp(new.startsAt)8 else "endsAt must be after startsAt";
Checks that depend on the old data, or on more than one field at once. Each one has its own else message, which is what the client sees in the 400.
Times a client can't choose for itself.
1// The client writes "$jsonpad-var:now" into these fields2require create: new.createdAt == now3 else "write \"$jsonpad-var:now\" to /createdAt";45require update: unchanged("/createdAt") && new.updatedAt == now6 else "write \"$jsonpad-var:now\" to /updatedAt";
Rules never change the data, so a server-stamped field works the other way round: the client writes the literal "$jsonpad-var:now", JSONPad substitutes it before the rules run, and the rule insists the result is the current time. A client that stamps a time of its own choosing is refused.
An array that can only ever grow.
1require update "log": startsWith(new.events, old.events)2 && length(new.events) == length(old.events) + 13 else "an update appends exactly one entry to /events";
startsWith works on arrays as well as strings, so this says the new array begins with the old one — nothing already written can be changed or removed.
A status field with allowed transitions.
1let transitions = {2 draft: ["submitted"],3 submitted: ["approved", "rejected"],4 rejected: ["draft"],5 approved: []6};78require update "status": unchanged("/status")9 || new.status in (transitions[old.status] ?? [])10 else "that status change isn't allowed";
An object literal in a let makes a lookup table. The ?? [] means an unknown starting status allows nothing, rather than erroring.
A counter that can only go up by one.
1require update "count goes up by one": new.count == old.count + 12 && onlyChanged("/count")3 else "count can only go up by 1";
Two clients that both read count as 4 will both try to write 5. The item is re-read and locked before the write is saved, so the second one is refused with 409 rather than quietly overwriting the first. Fetch and retry.
Players share items and move in turn.
1// Identities may write items they don't own2shared update;34function nextPlayer(g) =5 g.participantIds[6 (indexOf(g.participantIds, g.currentPlayerId) + 1) % length(g.participantIds)7 ];89allow update "move": identity != null10 && old.status == "started"11 && identity.id == old.currentPlayerId12 && onlyChanged(13 "/moves", "/board", "/currentPlayerId", "/turnStartedAt", "/status"14 );1516require update "log": startsWith(new.moves, old.moves)17 && length(new.moves) == length(old.moves) + 118 && last(new.moves).playerId == identity.id19 else "a move appends exactly one entry, made by you";2021require update "turn advances": new.currentPlayerId == nextPlayer(old)22 else "currentPlayerId must be the next player";
The whole of a small multiplayer game, enforced on our side. shared update lets players write the game item even though only one of them owns it; the allow statement decides whose turn it is; and the two require statements make sure a move appends exactly one entry, made by the player making it, and hands the turn on.
A timeout any client can fire once due.
1let isPlayer = identity != null && identity.id in old.participantIds;23allow update "timeout": isPlayer4 && now > old.turnStartedAt + 5m5 && new.lastEventType == "timed-out"6 && new.turnStartedAt == now7 && onlyChanged("/currentPlayerId", "/turnStartedAt", "/lastEventType");
JSONPad has no scheduler, and this doesn't need one. Any player may fire the timeout, but only once it's really due by the server's clock, and only if the write changes nothing but the timeout fields. Nothing happens until someone asks — which, in a game, someone always does.
Hidden information a player can't change later.
1require update "commitment is fixed": unchanged("/gridCommitment")2 else "gridCommitment can't be changed";34require update "reveal matches": new.reveal == null5 || sha256(new.reveal.grid + ":" + new.reveal.salt) == old.gridCommitment6 else "revealed grid doesn't match the commitment";
For hidden information a player mustn't be able to change after the fact. They write a hash of their secret first; when they reveal it, the rule checks the hash matches. Everyone can verify the game was played honestly, and the server never has to keep a secret.
Your backend bypasses, clients are ruled.
1// Give your backend's token the "server" tag2allow write "backend": "server" in token.tags;34allow update "clients": identity != null && oldItem.identityId == identity.id;
Rules apply to every token, so give your backend's token a tag and let it through first. The order doesn't matter — any one allow passing is enough.
Whichever you start from, write a couple of rule tests for it: one write that should be allowed, and one that shouldn't. They run every time the rules are saved, so a later change can't quietly undo what you meant.