This guide adds access rules to the manual synced-app quickstart. It applies to the optimistic client and application API server. Other browser clients need authorization at their own server boundary.
Your HTTP adapter authenticates the request. Rindle does not verify a session
cookie or JWT for you. The adapter passes the verified identity as ctx.user to
handleQueryJson, handleReadJson, or handleMutateJson.
The quickstart’s x-user header is only a development identity. Replace that
adapter logic with your authentication system before exposing private data.
Restrict a named query
Create a query that returns only the caller’s issues. This module imports the query builder defined in the quickstart:
// shared/private-queries.ts
import { defineQuery } from "@rindle/client";
import { z } from "zod";
import { q } from "./app-def.ts";
const args = z.object({ limit: z.number().int().min(1).max(100) });
export const myIssuesQuery = defineQuery(
"myIssues",
(raw) => args.parse(raw),
({ limit }, ctx: { user: string | undefined }) => {
if (!ctx.user) throw new Error("Sign in to read your issues");
return q.issue.where.ownerId(ctx.user)
.orderBy("createdAt", "desc").orderBy("id", "asc").limit(limit);
},
);
Register this query in server/api.ts, replacing the quickstart’s public issue
registry if those rows should now be private:
import { myIssuesQuery } from "../shared/private-queries.ts";
const queries = registerQueries<User>([myIssuesQuery]);
Keep the quickstart’s authorizeQuery gate and pass this queries object to
createRindleApiServer. Do not leave an unrestricted query registered for the
same private dataset.
The browser supplies its local identity when it builds the prediction:
import { app } from "./rindle-client.ts";
import { myIssuesQuery } from "../shared/private-queries.ts";
const view = app.store.materialize(myIssuesQuery({ limit: 20 }, { user: "demo" }));
// Use your signed-in session's user ID in place of the demo identity.
// When the consumer is finished:
view.destroy();
Only the query name and arguments travel in this request. The server supplies its
own authenticated context; it does not trust the browser’s user value.
A request gate controls whether a query can run. Its filters control which rows that query returns. Use both where required, including for server-rendered reads. Authorization checks run when a lease is created or renewed; do not treat them as a per-row callback on every streamed change.
Enforce write rules independently
A read filter does not grant or deny writes. The server must check each mutation’s permissions against authoritative data. The browser may predict against an incomplete local dataset.
For example, replace the quickstart’s deleteIssue entry with this shared body:
// shared/app-def.ts: add Row to the existing type imports.
import type { Row } from "@rindle/client";
// Inside the mutators object, using the existing shared, z, and issue imports:
deleteIssue: shared(
z.object({ id: z.string() }),
function* (tx, a, ctx): MutationGen {
const current = (yield tx.row("issue", { id: a.id })) as Row<typeof issue> | undefined;
if (!current) return;
if (current.ownerId !== ctx.user) throw new Error("Only the owner can delete this issue");
yield tx.delete("issue", { id: a.id });
},
),
The quickstart’s sharedCtx supplies authenticated ctx.user on the server.
The browser supplies a local principal for prediction. A malicious client can
change its prediction, but cannot change the identity verified by your adapter.
A local throw prevents the mutation from being enqueued. A server throw rejects the authoritative mutation and rolls back its writes. A body that returns without writing is an accepted no-op. Choose the behavior your UI should report.
For checks that belong only on the server, wrap or override the named mutator with an API mutator. See the complete wiring in The API server.
Keep authorization separate from presentation
Hide unavailable actions to help users understand their permissions. Still enforce the same rules on the server. Server-only query resolvers can apply additional filters, but their security comes from the returned data being restricted, not from keeping the filter expression secret.
Handle rejected writes in the UI and keep database tokens in trusted server code. Query leases grant access to a result; they are not database-wide credentials.