Skip to content

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.

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 own clientGroupID; 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 every auth.read / auth.write predicate.

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 see row on pull. false → the row is filtered out of the pull response.
  • write(ctx, row) — decides whether the caller may insert / update / delete row. false → the mutator throws PlasmaAuthorizationError; the transaction rolls back; _plasma_client_mutations.last_mutation_id still 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.

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.

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.write would reject, the local mutation lands in IDB, and the server rejects it on push. Your onError handler fires and the local view reverts on the next rebuildOptimistic.

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.

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:

  1. Fetches every ref for that hash (up to readAuthMaxRefs, default 128).
  2. For each ref, fetches the referencing row and runs its table’s auth.read(ctx, row).
  3. 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.

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.

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.