Auth and Permissions
plasma has two auth boundaries. The request boundary identifies the caller once per HTTP call. The row boundary — a pair of predicates per table — decides which specific rows this caller may read or write.
Request auth — SyncHandlerOptions.auth
Section titled “Request auth — SyncHandlerOptions.auth”The sync handler calls your auth function on every request to
/sync/*. Return { ok: true, ... } to accept and give the mutator
its context, or { ok: false, reason } to reject:
createSyncHandler({ schema, mutators, executor, schemaVersion: SCHEMA_VERSION, auth: async (req) => { const token = req.headers.get("authorization")?.replace("Bearer ", "") if (!token) return { ok: false, reason: "missing token" }
const user = await verifyToken(token) if (!user) return { ok: false, reason: "invalid token" }
return { ok: true, clientGroupID: user.id, clientID: req.headers.get("x-client") ?? "unknown", ctx: { userId: user.id, role: user.role }, } },})The returned object’s shape:
clientGroupID— the logical installation. Must match what the client sent as its ownclientGroupID; if not, the sync handler responds 400.clientID— the per-tab id. Must match what the client sent.ctx— passed to every subsequent mutator invocation as the third param, and to everyauth.read/auth.writepredicate.
Row auth — TableOptions.auth
Section titled “Row auth — TableOptions.auth”Every table can declare per-row predicates:
interface Ctx { userId: string }
const todos = table("todos", { id: id(), title: text(), userId: ref(() => users.id),}, { auth: { read: (ctx: Ctx, row) => row.userId === ctx.userId, write: (ctx: Ctx, row) => row.userId === ctx.userId, },})The (ctx: Ctx, row) => ... annotation on the first callback
lets TypeScript infer TCtx for the whole table’s auth block, so
ctx.userId on the right side is fully typed. Same Ctx used by
defineMutators<typeof schema, Ctx>()({...}) — declare it once in
your shared schema file.
read(ctx, row)— decides whether the caller may seerowon pull.false→ the row is filtered out of the pull response.write(ctx, row)— decides whether the caller may insert / update / deleterow.false→ the mutator throwsPlasmaAuthorizationError; the transaction rolls back;_plasma_client_mutations.last_mutation_idstill advances so the client’s outbox drops the poison entry.
Both predicates are synchronous — no async, no external calls.
Reach for ctx fields you cached in your auth() handler.
What row looks like
Section titled “What row looks like”For inserts, row is the post-defaults concrete row. For updates,
write is called with the new row (post-update). For deletes,
write is called with the current row (pre-delete).
For reads on pull, row is the row as it exists in the change log
(_plasma_changes.value decoded). Auth runs on the pull server so
the caller never sees a row read rejected — the row is silently
excluded.
Isomorphism gotcha
Section titled “Isomorphism gotcha”auth.read / auth.write are declared on TableOptions, which is
part of the isomorphic schema. That means the same predicate
technically runs on both engines. In practice:
- Server: plasma runs these on every push / pull. This is the authoritative check.
- Client: plasma does NOT run them by default. The client
operates optimistically; if you write a row your own
auth.writewould reject, the local mutation lands in IDB, and the server rejects it on push. YouronErrorhandler fires and the local view reverts on the nextrebuildOptimistic.
The rationale: enforcing row-level auth on the client would require running the predicates on every mutation and eating the code-path cost even when the user is doing something legitimate. It’s cheaper to let the optimistic path go and let the server reject the rare outlier.
Blob-read auth — content-addressable
Section titled “Blob-read auth — content-addressable”file() columns don’t use auth.read directly. Instead, the server
tracks _plasma_blob_refs(hash, table, row_id, column) — a reverse
index of every row that mentions each blob hash.
When a client requests GET /sync/blob/:hash, the server:
- Fetches every ref for that hash (up to
readAuthMaxRefs, default 128). - For each ref, fetches the referencing row and runs its table’s
auth.read(ctx, row). - If any referring row passes
auth.read, the blob is served.
This handles dedup: two users each attaching the same image get one blob shared between them, and each can read it because their own row grants them access.
Consequence
Section titled “Consequence”If a user knows a blob hash, they can grant themselves read by
referencing it from a row of their own (an inserted note pointing at
{ hash: ..., size, mime }). This is a dedup-vs-revoke trade-off:
plasma optimises for dedup. Sensitive blobs should live under a
separate storageRef bucket with per-table stricter auth once
SyncHandlerOptions.blobs supports per-table adapters.
Rejecting with a reason
Section titled “Rejecting with a reason”auth returning { ok: false, reason } produces a 401 on the
wire — surfaces on the client as push-http / pull-http with
status: 401. PlasmaAuthorizationError inside a mutator body
does NOT reach client.onError: the push still returns
{ ok: true }, _plasma_client_mutations.last_mutation_id still
advances (so the poison drops), and the failed mutation’s row
visually reverts on the next rebuildOptimistic. Two different
failure modes with two different visible signatures.
What to read next
Section titled “What to read next”- Concepts / clientID vs clientGroupID
— the identities
authreturns - Files and blobs — full blob storage flow
- Concepts / Push, Pull, Rebase — where
authfits in each phase