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:
- Authenticate — derive the principal server-side, ignoring anything the client claims.
- Gate reads — resolve each named query to an AST scoped to that principal, which the client can’t widen.
- Gate writes — drive each mutator with the authenticated
ctx.user, andthrowto 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
- The API server — named-query resolution,
defineApiQueries,sharedApiMutators, and theauthorize*hooks in full. - Isomorphic mutators —
ctx.user, the off-wire principal, and no-op-for-non-owner. - Handling rejected writes — what the client sees when a server guard throws.