Next.js and Prisma
A Next.js app (App Router) on Prisma 7, whose migrations are Prisma Migrate's. Route handlers, server actions and pages check nothing: each transaction signs in as the request's user, the database filters what it reads and refuses what it may not write, and the SDK answers a refusal with 403 and a hidden row with 404. Signed-in reads never land in a cache another user could be served from.
Everything here comes from integrations/nextjs, the conformance suite: a small app and the checks every supported stack passes (integrations/nextjs/test.sh, against the production build). Drizzle, pg and postgres.js: Node apps.
Install
npm install @rowfence/client @rowfence/prisma @rowfence/next @rowfence/react
npm install --save-dev rowfence @rowfence/vitest
npx rowfence init # a first policy from your tables, a test file, rowfence.tomlThe rowfence package is the command, with its own Python: nothing else to install. init finds Next.js and Prisma, and writes rowfence.toml for them. The conformance app's, with its own name for the variable that holds the owner's connection:
policy = "db/policy.authz"
tests = ["db/tests/*.authz"]
database = "env:ROWFENCE_OWNER_DSN"
[clients]
ts = "src/authz.gen.ts"
[migrations]
tool = "prisma"
dir = "prisma/migrations"Two roles connect. The owner of the tables runs the migrations (ROWFENCE_OWNER_DSN), and the app connects as the role the policy names, which row-level security applies to (ROWFENCE_APP_URL).
The database client
signedIn wraps Prisma's driver adapter: every transaction Prisma begins signs in as whoever user returns for the request (null: nobody, who sees only what anyone may). authz() turns Prisma's "record not found" into 404 or 403 with the reason, and adds db.$authz. Use the client $extends(authz()) returns, and only that one: the client it was made from is refused whenever it is used. Importing @rowfence/next keeps signed-in reads out of Next.js's caches, and authz.gen.ts (written by rowfence client) gives the SDK the policy's names, so a misspelled permission doesn't type-check.
import { PrismaPg } from "@prisma/adapter-pg";
import { headers } from "next/headers";
import pg from "pg";
import { authz, signedIn } from "@rowfence/prisma";
import "@rowfence/next"; // signed-in reads never land in a cache
import { PrismaClient } from "./generated/prisma/client.ts";
import "./authz.gen.ts"; // the policy's names, for the SDK's types
/** Who the request is. A real app reads its session (auth()); the conformance app, a header. */
export async function requestUser(): Promise<string | null> {
return (await headers()).get("x-user");
}
const url = process.env.ROWFENCE_APP_URL;
export const pool = new pg.Pool({ connectionString: url, max: Number(process.env.PG_POOL_MAX ?? 5) });
export const db = new PrismaClient({ adapter: signedIn(new PrismaPg(pool), { user: requestUser }) }).$extends(authz());At start-up, the server stops if its connection skips row-level security (a superuser, BYPASSRLS, the tables' owner), where the database would filter nothing:
// instrumentation.ts
import { checkAtStart } from "@rowfence/next";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") await checkAtStart((await import("./src/db.ts")).db.$authz);
}Pages, route handlers and server actions
A page reads as the request's user:
export default async function Notes() {
const notes = await db.note.findMany({ select: { id: true, body: true }, orderBy: { id: "asc" } });route() answers a refusal with a problem body: 404 for a row the user can't see, 403 with the rule and the reason for one they may not change.
import { route } from "@rowfence/next";
import { db } from "@/db";
type Params = { params: Promise<{ id: string }> };
// an update the rules may refuse: a hidden note is 404, one the user may not edit 403 with the reason
export const PATCH = route(async (req: Request, { params }: Params) => {
const id = Number((await params).id);
const { body } = (await req.json()) as { body: string };
await db.note.update({ where: { id }, data: { body } });
return Response.json({ id });
});Next.js hides a server action's thrown error from the browser, so action() returns the problem instead, { ok: false, problem }:
"use server";
import { action } from "@rowfence/next";
export const renameNote = action(async (id: number, body: string) => {
await db.note.update({ where: { id }, data: { body } });
return id;
});Prisma's create reads the new row back, so an insert also needs the select rule; if the user may insert but not read the row, the 403 names the select rule.
Two Prisma habits are handled: each findUnique runs in a transaction of its own (Prisma answers the calls of one tick together, from the first caller's context), and array transactions, $transaction([...]), are refused for the same reason. Use $transaction(async (tx) => ...). Those transactions wait for a connection as long as Prisma's transactionOptions say, 2 seconds by default: with the database far away and many findUnique calls at once, give the client a longer maxWait (or a bigger pool), or they fail with "Unable to start a transaction in the given time".
Buttons, sharing and live updates (React)
authzRoutes() is what the React kit calls: permissions for a list, shares, access requests, and live updates from the change feed.
// app/api/authz/[...authz]/route.ts
import { authzRoutes } from "@rowfence/next";
import { db, feed } from "@/db";
export const { GET, POST } = authzRoutes({ calls: db.$authz, changes: feed });import { changes } from "@rowfence/pg";
// live updates for @rowfence/react: one connection that LISTENs, beside the pool, straight to Postgres
// (through a pooler in transaction mode LISTEN hears nothing: ROWFENCE_FEED_URL is the direct URL then)
export const feed = changes(new pg.Pool({ connectionString: process.env.ROWFENCE_FEED_URL ?? url, max: 1 }));usePerms answers a list's buttons in one call, and <Can> shows what the user may do:
function Buttons() {
const perms = usePerms("project", [1, 2, 3]);
if (perms.loading) return <p>loading</p>;
return <ul>{[1, 2, 3].map((id) => <li key={id}>{id}:{perms(id).perms.join(",")}{perms(id).can("edit") && " [rename]"}</li>)}</ul>;
}
<Can type="project" id={1} perm="edit" fallback={<span>no edit on 1</span>}><span>edit on 1</span></Can>useShares and a headless <ShareDialog> share and unshare as the database allows; useAccessRequest asks for access. They stay current over server-sent events.
Background jobs
A job acts for a principal of its own (type service = app.services principal in the policy), whatever started it: after(), a queue worker, a cron route.
import { current, job } from "@rowfence/client";
export const digest = job(["service", 1], async () => ({ count: await db.project.count(), who: current() }));Anywhere else, actingAs("3", () => ...) signs in what runs inside it.
Migrations
The policy ships as Prisma migrations. rowfence migrate writes the next one (only what changed since db/policy.lock), and Prisma applies it with the others; Prisma's own diff then shows no change.
npx rowfence migrate # after changing the policy
npx prisma migrate deploy
npx rowfence migrate --check # in CI: exit 1 if a policy change has no migrationWhile you edit, npx rowfence dev checks, pushes to the development database, runs the tests and rewrites src/authz.gen.ts on every save (and writes the migration once you stop editing).
Tests
The policy's own tests run with rowfence test. In Vitest, the matchers read the refusal:
import { expect } from "vitest";
import { matchers } from "@rowfence/vitest";
expect.extend(matchers); await expect(actingAs("3", () => db.$transaction((tx) => tx.note.update({ where: { id: 1 }, data: { body: "x" } }))))
.rejects.toBeRefused("update", "note.edit");
await expect(actingAs("2", () => db.note.delete({ where: { id: 1 } }))).rejects.toBeNotFound();databasePerWorker(ownerUrl) copies the migrated test database once for each Vitest worker, so tests that write don't meet each other. The copy needs nobody connected to the original; where the service keeps one (Neon does, for minutes after a connection), it says so and what to use instead (Managed Postgres).