Recipes

Authorizing reads & writes

The wire carries only `(name, args)` — identity never crosses it. The API server derives the principal from its own session, resolves each named query to a scoped AST the client can't widen, and drives every mutator with the authenticated `ctx.user`. Read scope is encoded into the query; write scope is a guard in the mutator that `throw`s and snaps back.

View as Markdown

Authorization in a Rindle app rests on one fact: the wire carries only { name, args } — never the actor. A client names a query and a mutation; the API server supplies the identity from its own trusted session. So there’s no owner arg to tamper with, and three seams to get right:

  1. Authenticate — derive the principal server-side, ignoring anything the client claims.
  2. Gate reads — resolve each named query to an AST scoped to that principal, which the client can’t widen.
  3. Gate writes — drive each mutator with the authenticated ctx.user, and throw to refuse.

Authenticate: the principal is off-wire

The client predicts under its session user so its optimistic rows match, but the authority re-derives the identity from its session cookie and never trusts a header. ctx.user is that authenticated value.

Gate reads: scope the query, don’t add an ACL layer

A named query resolves on the server to a byte-identical AST — a client can’t smuggle a different shape. To scope it, encode the principal into the AST. A context-scoped query takes the authenticated ctx and filters by it:

// defined ONCE, runs on both tiers — the OWNER is not an arg
export const myIssuesQuery = defineQuery(
  "myIssues",
  validateMyIssuesArgs,                                  // → { limit }
  ({ limit }, ctx: { user: string | undefined }) =>
    q.issue.where.ownerId(ctx.user ?? "").orderBy("createdAt", "desc").limit(limit),
);
// wire carries { name: "myIssues", args: { limit } } — the user is re-derived, never sent

When the server must add a filter the client shouldn’t even see — a tenancy or sharing rule — register a server-only resolver under the same name; it wins, and the client never learns the extra clause:

import { defineApiQueries, registerQueries } from "@rindle/api-server";

const serverOnly = defineApiQueries({
  issuesPage: (ctx, args) => issuesPageQuery.resolve(args).where.ownerId(requireUser(ctx.user)),
});
const apiQueries = { ...registerQueries([issuesPageQuery]), ...serverOnly };  // later entry wins

A blanket authorizeQuery(ctx) gate can also refuse a caller outright (e.g. no session at all) before any query resolves.

Gate writes: the mutator runs as ctx.user, and a throw refuses

sharedApiMutators drives your isomorphic mutators on the server with the authenticated principal injected as ctx.user — never a client-supplied author. A precondition that fails just throws: on the server that rolls the transaction back, and the client’s optimistic write snaps back on its own.

// a server-authoritative guard: read the row, refuse if the caller isn't the owner
const user = requireUser(ctx.user);              // throws if unauthenticated → rejected
const deck = await tx.query(q.deck.where.id(a.id).where(owned.owner_id(user)).one());
if (deck == null) throw new RindleApiError("forbidden", "not your deck", 403);

Ownership can also be expressed inside the shared body as a no-op — a write that simply does nothing for a non-owner, identically on both tiers. Either way, identity comes from ctx.user, so it’s the same on the optimistic prediction and the authoritative run.

Defense in depth, not UI trust

Hiding an edit button is a UX nicety; it is not the authorization. Compute a canEdit in the client to hide affordances, and enforce it on the server — the server rejects a forbidden write even if the client sent it anyway.

In the wild — Strut

Strut authorizes a multi-tenant, shareable deck editor exactly this way. The principal is derived from the session and the client’s x-user header is ignored — this is the trust boundary:

// server/session.ts
export async function resolveSessionUser(request: Request): Promise<string> {
  const auth = await getAuth(request)
  const session = await auth.api.getSession({ headers: request.headers })
  return session?.user.id ?? ''
}

server/session.ts L12–24 · Strut

Reads are gated by a server twin of each query. The client’s decksQuery is un-scoped (shared/queries.ts L36–45); the server registers the same name with an access predicate — owner OR shared-with — that the client can’t remove:

// server/queries.ts — the authoritative twin
function deckAccess(user: string) {
  return or(deck.owner_id(user), existsNoSync(rels.deckShares, (s) => s.where.user_id(user)))
}
const decksQuery = defineQuery('decks', /* validate */, ({ limit }, ctx: Ctx) =>
  q.deck.where(deckAccess(ctx.user)).include(DeckFragment).orderBy('modified', 'desc')
    .limit(limit).countAs('slideCount', rels.deckSlides),
)

server/queries.ts L40–56 · Strut

The API server wires the principal in and gates every query/mutation on a present session:

// server/rindle-api.ts
const ctx = { user: await resolveSessionUser(request), request }
// …
authorizeQuery:    ({ user }) => typeof user === 'string' && user.length > 0,
authorizeMutation: ({ user }) => typeof user === 'string' && user.length > 0,

server/rindle-api.ts L405–423 · Strut — write guards like setDeckVisibilityGuarded add per-mutation ownership + entitlement checks that throw a 403 (see handling rejected writes).

On the read side, the editor computes canEdit (owner, or an editor-role share) to hide editing chrome — defense in depth, since the server rejects a disallowed write regardless:

// src/routes/deck.$deckId.tsx
const canEdit =
  !!deck &&
  (deck.owner_id === me || shares.some((s) => s.user_id === me && s.role === 'editor'))

src/routes/deck.$deckId.tsx L60–63 · Strut

See also