Scoped data, the action wrapper, role changes
This lesson installs the two helpers the rest of the app rests on, the scoped-data facade and the privileged-action wrapper, then ships the first action that uses both: an admin changing a member’s role from the inspector.
The two most common bugs in a multi-tenant app are a query that forgets its WHERE organization_id = ?, leaking one tenant’s rows to another, and a mutation that forgets to check the caller’s role, letting a member do an admin’s job. Code review can only catch them so often. The durable fix is to make both impossible to express: a data path that won’t compile without the org filter, and an action shape that runs the role gate before your code does. That is what tenantDb(orgId) and authedAction(role, schema, fn) are. By the end of the lesson, as the admin Bob you change Carol’s role and watch a member.role-changed row appear in the inspector’s audit tail; as the member Carol you try the same change and get an inline forbidden with nothing written; and demoting Alice, Acme’s sole owner, returns a conflict naming the last-owner rule.
Your mission
Section titled “Your mission”You are building two reusable primitives and one action that exercises them. Every tenant read and every privileged write in the rest of the app will go through these exact shapes, so the decisions here outlive this lesson.
tenantDb(orgId) is the only scoped data path in the app.
It composes the org predicate as the outer and on every read, update, and delete, and injects organizationId on insert, throwing if the caller supplies a different one.
There is deliberately no bypass on the facade: the only unscoped path is the bare db import, reserved for scripts/.
One constraint to respect: tenantDb does app-layer scoping only and does not set app.org_id, so the audit-bearing write goes through the separate withTenant(orgId, ...) from the last lesson while ordinary reads stay on the facade.
authedAction(role, schema, fn) is the only shape a privileged Server Action takes.
It runs four fixed-order steps: resolve the caller, authorize, parse the form, then call your fn with a ctx that carries the user, org, role, an ip/userAgent pair, and an already-scoped db: tenantDb(orgId).
Authorize runs before parse so the cheapest gate fails fastest, and every refusal returns a typed err(...) Result rather than throwing.
changeMemberRole is the action.
It refuses owner targets and the last-owner demotion, both as conflict.
Its role update and member.role-changed audit row co-transact in one withTenant, so the role change rolls back if the audit insert fails.
The role-change <Select> is rendered to every acting identity in the inspector, including under-privileged ones, on purpose: the defense being tested is the server-side refusal, not a client-side hide.
Out of scope here are the remove, leave-org, and ownership-transfer actions; changeMemberRole is the load-bearing one.
tenantDb(orgId) read returns only rows for that org, and a caller’s where narrows within the org rather than escaping it.tenantDb(orgId) insert persists with organizationId set to that org even when the caller omits it, and throws when the caller supplies a mismatched one.tenantDb(orgId).query.user is a type error — global tables are unreachable through the facade.authedAction whose required role exceeds the caller’s role returns err('forbidden', ...) with a user-safe message and never throws.authedAction with input that fails the schema returns err('validation', ..., fieldErrors) and never reaches the action body.member.role-changed audit row with payload: { before, after } and actorUserId matching the admin.forbidden, the role row is unchanged, and no audit row is added.conflict; targeting the sole owner returns conflict with the last-owner message; neither changes the database.auditLogs count increments only on a successful role change, never on a rejected attempt.Coding time
Section titled “Coding time”Build it against the brief and the lesson’s tests first. The reference solution below is collapsed on purpose — open it once you have something running, or when a specific piece won’t come together.
Reference solution and walkthrough
The scoped-data facade
Section titled “The scoped-data facade”You built tenantDb in chapter 056; here you add it beside the withTenant you shipped last lesson, in the same file. It is one object literal with a read surface, an insert, an update, and a delete.
// The org-owned tables this project scopes. The registry is the single source of// truth: the runtime backstop for the write methods and the type source for the// query surface, so tenantDb(orgId).query.user is a type error (user is a global// table, never tenant-scoped here).const TENANT_TABLES = { member, invitation } as const;
type TenantTable = (typeof TENANT_TABLES)[keyof typeof TENANT_TABLES];
// App-layer tenant scoping only — this facade does NOT set app.org_id. The// audit-bearing write uses the separate withTenant. The org predicate is always the// OUTER and so a caller's or(...) becomes a contradiction, not an escape hatch. There// is no .raw / allOrgs bypass: the only unscoped path is the separately-imported db,// reserved for scripts. The read wrappers forward the original method type via a cast// so callers keep the full generic config → BuildQueryResult inference (a naive// wrapper typed Promise<unknown[]> collapses the with-expansion and joined relations// stop resolving).export const tenantDb = (orgId: string) => ({ query: { member: { findMany: ((config?: { where?: SQL }) => db.query.member.findMany({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findMany, findFirst: ((config?: { where?: SQL }) => db.query.member.findFirst({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findFirst, }, invitation: { findMany: ((config?: { where?: SQL }) => db.query.invitation.findMany({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findMany, findFirst: ((config?: { where?: SQL }) => db.query.invitation.findFirst({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findFirst, }, }, insert: <T extends TenantTable>(table: T) => { const builder = db.insert(table); return { values: (value: Omit<T['$inferInsert'], 'organizationId'>) => { const supplied = (value as { organizationId?: string }).organizationId; if (supplied !== undefined && supplied !== orgId) { throw new Error( 'tenantDb insert: organizationId may not be overridden', ); } return builder.values({ ...value, organizationId: orgId, } as PgInsertValue<T>); }, }; }, update: <T extends TenantTable>(table: T) => { const builder = db.update(table); return { set: (value: PgUpdateSetSource<T>) => ({ where: (where?: SQL) => builder.set(value).where(and(eq(table.organizationId, orgId), where)), }), }; }, delete: <T extends TenantTable>(table: T) => ({ where: (where?: SQL) => db.delete(table).where(and(eq(table.organizationId, orgId), where)), }),});The registry is both the runtime backstop for the writes and the type source for the query surface. Since user is not in it, tenantDb(orgId).query.user is a compile error — requirement 3 falls straight out of this declaration.
// The org-owned tables this project scopes. The registry is the single source of// truth: the runtime backstop for the write methods and the type source for the// query surface, so tenantDb(orgId).query.user is a type error (user is a global// table, never tenant-scoped here).const TENANT_TABLES = { member, invitation } as const;
type TenantTable = (typeof TENANT_TABLES)[keyof typeof TENANT_TABLES];
// App-layer tenant scoping only — this facade does NOT set app.org_id. The// audit-bearing write uses the separate withTenant. The org predicate is always the// OUTER and so a caller's or(...) becomes a contradiction, not an escape hatch. There// is no .raw / allOrgs bypass: the only unscoped path is the separately-imported db,// reserved for scripts. The read wrappers forward the original method type via a cast// so callers keep the full generic config → BuildQueryResult inference (a naive// wrapper typed Promise<unknown[]> collapses the with-expansion and joined relations// stop resolving).export const tenantDb = (orgId: string) => ({ query: { member: { findMany: ((config?: { where?: SQL }) => db.query.member.findMany({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findMany, findFirst: ((config?: { where?: SQL }) => db.query.member.findFirst({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findFirst, }, invitation: { findMany: ((config?: { where?: SQL }) => db.query.invitation.findMany({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findMany, findFirst: ((config?: { where?: SQL }) => db.query.invitation.findFirst({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findFirst, }, }, insert: <T extends TenantTable>(table: T) => { const builder = db.insert(table); return { values: (value: Omit<T['$inferInsert'], 'organizationId'>) => { const supplied = (value as { organizationId?: string }).organizationId; if (supplied !== undefined && supplied !== orgId) { throw new Error( 'tenantDb insert: organizationId may not be overridden', ); } return builder.values({ ...value, organizationId: orgId, } as PgInsertValue<T>); }, }; }, update: <T extends TenantTable>(table: T) => { const builder = db.update(table); return { set: (value: PgUpdateSetSource<T>) => ({ where: (where?: SQL) => builder.set(value).where(and(eq(table.organizationId, orgId), where)), }), }; }, delete: <T extends TenantTable>(table: T) => ({ where: (where?: SQL) => db.delete(table).where(and(eq(table.organizationId, orgId), where)), }),});Each wrapper spreads the caller’s config and overwrites where with the org predicate as the outer and, so a caller’s or(...) narrows within the org. The cast is load-bearing: it forwards Drizzle’s original method type, keeping the caller’s full generic config → BuildQueryResult inference. Typed as Promise<unknown[]> instead, it would collapse the with-expansion and the joined relations listMembers needs would stop resolving.
// The org-owned tables this project scopes. The registry is the single source of// truth: the runtime backstop for the write methods and the type source for the// query surface, so tenantDb(orgId).query.user is a type error (user is a global// table, never tenant-scoped here).const TENANT_TABLES = { member, invitation } as const;
type TenantTable = (typeof TENANT_TABLES)[keyof typeof TENANT_TABLES];
// App-layer tenant scoping only — this facade does NOT set app.org_id. The// audit-bearing write uses the separate withTenant. The org predicate is always the// OUTER and so a caller's or(...) becomes a contradiction, not an escape hatch. There// is no .raw / allOrgs bypass: the only unscoped path is the separately-imported db,// reserved for scripts. The read wrappers forward the original method type via a cast// so callers keep the full generic config → BuildQueryResult inference (a naive// wrapper typed Promise<unknown[]> collapses the with-expansion and joined relations// stop resolving).export const tenantDb = (orgId: string) => ({ query: { member: { findMany: ((config?: { where?: SQL }) => db.query.member.findMany({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findMany, findFirst: ((config?: { where?: SQL }) => db.query.member.findFirst({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findFirst, }, invitation: { findMany: ((config?: { where?: SQL }) => db.query.invitation.findMany({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findMany, findFirst: ((config?: { where?: SQL }) => db.query.invitation.findFirst({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findFirst, }, }, insert: <T extends TenantTable>(table: T) => { const builder = db.insert(table); return { values: (value: Omit<T['$inferInsert'], 'organizationId'>) => { const supplied = (value as { organizationId?: string }).organizationId; if (supplied !== undefined && supplied !== orgId) { throw new Error( 'tenantDb insert: organizationId may not be overridden', ); } return builder.values({ ...value, organizationId: orgId, } as PgInsertValue<T>); }, }; }, update: <T extends TenantTable>(table: T) => { const builder = db.update(table); return { set: (value: PgUpdateSetSource<T>) => ({ where: (where?: SQL) => builder.set(value).where(and(eq(table.organizationId, orgId), where)), }), }; }, delete: <T extends TenantTable>(table: T) => ({ where: (where?: SQL) => db.delete(table).where(and(eq(table.organizationId, orgId), where)), }),});insert injects organizationId: orgId, throwing only if the caller supplies a different one (requirement 2’s throw branch). The value type is Omit<…, 'organizationId'>, so the org is never required at the call site.
// The org-owned tables this project scopes. The registry is the single source of// truth: the runtime backstop for the write methods and the type source for the// query surface, so tenantDb(orgId).query.user is a type error (user is a global// table, never tenant-scoped here).const TENANT_TABLES = { member, invitation } as const;
type TenantTable = (typeof TENANT_TABLES)[keyof typeof TENANT_TABLES];
// App-layer tenant scoping only — this facade does NOT set app.org_id. The// audit-bearing write uses the separate withTenant. The org predicate is always the// OUTER and so a caller's or(...) becomes a contradiction, not an escape hatch. There// is no .raw / allOrgs bypass: the only unscoped path is the separately-imported db,// reserved for scripts. The read wrappers forward the original method type via a cast// so callers keep the full generic config → BuildQueryResult inference (a naive// wrapper typed Promise<unknown[]> collapses the with-expansion and joined relations// stop resolving).export const tenantDb = (orgId: string) => ({ query: { member: { findMany: ((config?: { where?: SQL }) => db.query.member.findMany({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findMany, findFirst: ((config?: { where?: SQL }) => db.query.member.findFirst({ ...config, where: and(eq(member.organizationId, orgId), config?.where), })) as typeof db.query.member.findFirst, }, invitation: { findMany: ((config?: { where?: SQL }) => db.query.invitation.findMany({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findMany, findFirst: ((config?: { where?: SQL }) => db.query.invitation.findFirst({ ...config, where: and(eq(invitation.organizationId, orgId), config?.where), })) as typeof db.query.invitation.findFirst, }, }, insert: <T extends TenantTable>(table: T) => { const builder = db.insert(table); return { values: (value: Omit<T['$inferInsert'], 'organizationId'>) => { const supplied = (value as { organizationId?: string }).organizationId; if (supplied !== undefined && supplied !== orgId) { throw new Error( 'tenantDb insert: organizationId may not be overridden', ); } return builder.values({ ...value, organizationId: orgId, } as PgInsertValue<T>); }, }; }, update: <T extends TenantTable>(table: T) => { const builder = db.update(table); return { set: (value: PgUpdateSetSource<T>) => ({ where: (where?: SQL) => builder.set(value).where(and(eq(table.organizationId, orgId), where)), }), }; }, delete: <T extends TenantTable>(table: T) => ({ where: (where?: SQL) => db.delete(table).where(and(eq(table.organizationId, orgId), where)), }),});update and delete both compose and(eq(table.organizationId, orgId), where), so a scoped mutation can never touch another org’s row even if the caller’s where is loose or omitted.
The one bypass off the facade is the separately-imported db, reserved for scripts, so a cross-tenant query has to be written conspicuously out of band.
The members read
Section titled “The members read”listMembers is the inspector’s members-panel query, the facade in everyday use. With the org predicate composed for you, the caller writes only the join and the ordering.
import 'server-only';
import { asc } from 'drizzle-orm';
import { member } from '@/db/schema/auth';import { tenantDb } from '@/db/tenant';
// The members panel's read. Scoped through the facade — no manual where org_id; the// facade composes the org predicate as the outer and. with: { user: true } returns// each member's joined user row (name/email) for the panel's label.export const listMembers = async (orgId: string) => tenantDb(orgId).query.member.findMany({ with: { user: true }, orderBy: asc(member.createdAt), });with: { user: true } joins each member’s user row for the panel’s name and email label. This is the relational expansion the cast preserves: drop it and the join silently stops resolving.
The privileged-action wrapper
Section titled “The privileged-action wrapper”You built authedAction in chapter 057. It is a factory: hand it a required role, a Zod schema, and your action body, and it returns the (_prev, formData) shape useActionState calls. Four steps run in fixed order.
type OrgUser = Awaited<ReturnType<typeof requireOrgUser>>['user'];
export type AuthedCtx = { user: OrgUser; orgId: string; role: Role; db: ReturnType<typeof tenantDb>; ip: string | null; userAgent: string | null;};
// The only privileged Server Action shape. Four fixed-order steps — resolve →// authorize → parse → call — authorizing before parse so the cheapest gate fails// fastest. Refusals return a Result (never throw): a throw 500s the action and loses// the typed contract useActionState renders. The one correct throw is requireOrgUser's// redirect, which propagates. No logging / entitlements / rate-limit steps live here.export const authedAction = <TSchema extends z.ZodType, TOut>( role: Role, schema: TSchema, fn: (input: z.infer<TSchema>, ctx: AuthedCtx) => Promise<Result<TOut>>, ) => async ( _prev: Result<TOut> | null, formData: FormData, ): Promise<Result<TOut>> => { const { user, orgId, role: actual } = await requireOrgUser();
if (!roleAtLeast(actual, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors as Record<string, string[]>, ); }
const h = await headers(); return fn(parsed.data, { user, orgId, role: actual, db: tenantDb(orgId), ip: h.get('x-forwarded-for'), userAgent: h.get('user-agent'), }); };The context handed to the body: user, orgId, role, db: ReturnType<typeof tenantDb> (already scoped to this org), and the ip/userAgent pair. The body never constructs its own tenantDb — ctx.db is the one it should use for reads.
type OrgUser = Awaited<ReturnType<typeof requireOrgUser>>['user'];
export type AuthedCtx = { user: OrgUser; orgId: string; role: Role; db: ReturnType<typeof tenantDb>; ip: string | null; userAgent: string | null;};
// The only privileged Server Action shape. Four fixed-order steps — resolve →// authorize → parse → call — authorizing before parse so the cheapest gate fails// fastest. Refusals return a Result (never throw): a throw 500s the action and loses// the typed contract useActionState renders. The one correct throw is requireOrgUser's// redirect, which propagates. No logging / entitlements / rate-limit steps live here.export const authedAction = <TSchema extends z.ZodType, TOut>( role: Role, schema: TSchema, fn: (input: z.infer<TSchema>, ctx: AuthedCtx) => Promise<Result<TOut>>, ) => async ( _prev: Result<TOut> | null, formData: FormData, ): Promise<Result<TOut>> => { const { user, orgId, role: actual } = await requireOrgUser();
if (!roleAtLeast(actual, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors as Record<string, string[]>, ); }
const h = await headers(); return fn(parsed.data, { user, orgId, role: actual, db: tenantDb(orgId), ip: h.get('x-forwarded-for'), userAgent: h.get('user-agent'), }); };The generic signature: <TSchema extends z.ZodType, TOut>(role, schema, fn). The returned function is the (_prev, formData) => Promise<Result<TOut>> shape useActionState calls.
type OrgUser = Awaited<ReturnType<typeof requireOrgUser>>['user'];
export type AuthedCtx = { user: OrgUser; orgId: string; role: Role; db: ReturnType<typeof tenantDb>; ip: string | null; userAgent: string | null;};
// The only privileged Server Action shape. Four fixed-order steps — resolve →// authorize → parse → call — authorizing before parse so the cheapest gate fails// fastest. Refusals return a Result (never throw): a throw 500s the action and loses// the typed contract useActionState renders. The one correct throw is requireOrgUser's// redirect, which propagates. No logging / entitlements / rate-limit steps live here.export const authedAction = <TSchema extends z.ZodType, TOut>( role: Role, schema: TSchema, fn: (input: z.infer<TSchema>, ctx: AuthedCtx) => Promise<Result<TOut>>, ) => async ( _prev: Result<TOut> | null, formData: FormData, ): Promise<Result<TOut>> => { const { user, orgId, role: actual } = await requireOrgUser();
if (!roleAtLeast(actual, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors as Record<string, string[]>, ); }
const h = await headers(); return fn(parsed.data, { user, orgId, role: actual, db: tenantDb(orgId), ip: h.get('x-forwarded-for'), userAgent: h.get('user-agent'), }); };Step one, resolve. Its redirect, when there is no session or active org, is the one allowed throw in the flow: it propagates as a navigation rather than being caught.
type OrgUser = Awaited<ReturnType<typeof requireOrgUser>>['user'];
export type AuthedCtx = { user: OrgUser; orgId: string; role: Role; db: ReturnType<typeof tenantDb>; ip: string | null; userAgent: string | null;};
// The only privileged Server Action shape. Four fixed-order steps — resolve →// authorize → parse → call — authorizing before parse so the cheapest gate fails// fastest. Refusals return a Result (never throw): a throw 500s the action and loses// the typed contract useActionState renders. The one correct throw is requireOrgUser's// redirect, which propagates. No logging / entitlements / rate-limit steps live here.export const authedAction = <TSchema extends z.ZodType, TOut>( role: Role, schema: TSchema, fn: (input: z.infer<TSchema>, ctx: AuthedCtx) => Promise<Result<TOut>>, ) => async ( _prev: Result<TOut> | null, formData: FormData, ): Promise<Result<TOut>> => { const { user, orgId, role: actual } = await requireOrgUser();
if (!roleAtLeast(actual, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors as Record<string, string[]>, ); }
const h = await headers(); return fn(parsed.data, { user, orgId, role: actual, db: tenantDb(orgId), ip: h.get('x-forwarded-for'), userAgent: h.get('user-agent'), }); };Step two, authorize, before parse — the cheapest gate fails fastest. Returns err('forbidden', …) with a user-safe message (covering requirement 4), and never throws.
type OrgUser = Awaited<ReturnType<typeof requireOrgUser>>['user'];
export type AuthedCtx = { user: OrgUser; orgId: string; role: Role; db: ReturnType<typeof tenantDb>; ip: string | null; userAgent: string | null;};
// The only privileged Server Action shape. Four fixed-order steps — resolve →// authorize → parse → call — authorizing before parse so the cheapest gate fails// fastest. Refusals return a Result (never throw): a throw 500s the action and loses// the typed contract useActionState renders. The one correct throw is requireOrgUser's// redirect, which propagates. No logging / entitlements / rate-limit steps live here.export const authedAction = <TSchema extends z.ZodType, TOut>( role: Role, schema: TSchema, fn: (input: z.infer<TSchema>, ctx: AuthedCtx) => Promise<Result<TOut>>, ) => async ( _prev: Result<TOut> | null, formData: FormData, ): Promise<Result<TOut>> => { const { user, orgId, role: actual } = await requireOrgUser();
if (!roleAtLeast(actual, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors as Record<string, string[]>, ); }
const h = await headers(); return fn(parsed.data, { user, orgId, role: actual, db: tenantDb(orgId), ip: h.get('x-forwarded-for'), userAgent: h.get('user-agent'), }); };Step three, parse. On failure it returns err('validation', …, fieldErrors) so the form can highlight the bad field, and returns before fn runs (requirement 5).
type OrgUser = Awaited<ReturnType<typeof requireOrgUser>>['user'];
export type AuthedCtx = { user: OrgUser; orgId: string; role: Role; db: ReturnType<typeof tenantDb>; ip: string | null; userAgent: string | null;};
// The only privileged Server Action shape. Four fixed-order steps — resolve →// authorize → parse → call — authorizing before parse so the cheapest gate fails// fastest. Refusals return a Result (never throw): a throw 500s the action and loses// the typed contract useActionState renders. The one correct throw is requireOrgUser's// redirect, which propagates. No logging / entitlements / rate-limit steps live here.export const authedAction = <TSchema extends z.ZodType, TOut>( role: Role, schema: TSchema, fn: (input: z.infer<TSchema>, ctx: AuthedCtx) => Promise<Result<TOut>>, ) => async ( _prev: Result<TOut> | null, formData: FormData, ): Promise<Result<TOut>> => { const { user, orgId, role: actual } = await requireOrgUser();
if (!roleAtLeast(actual, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors as Record<string, string[]>, ); }
const h = await headers(); return fn(parsed.data, { user, orgId, role: actual, db: tenantDb(orgId), ip: h.get('x-forwarded-for'), userAgent: h.get('user-agent'), }); };Step four, call the body with the typed parsed.data and the assembled ctx — ip/userAgent read from await headers(), db set to tenantDb(orgId).
Two decisions to carry forward: authorize before parse, because the role gate is cheaper than schema parsing and there is no point validating a caller who fails it; and every refusal returns a Result, never a throw. A throw 500s the action and leaves the form nothing typed to render, while an err(...) flows back through useActionState as the inline message the user sees.
The role-change action
Section titled “The role-change action”changeMemberRole is an authedAction('admin', schema, fn), so by the time your body runs the wrapper has resolved the caller, refused anyone below admin, and parsed the form. The body handles the two business rules, protect owners and protect the last owner, and the co-transacted write.
'use server';
import { and, eq } from 'drizzle-orm';import { revalidatePath } from 'next/cache';import { z } from 'zod';
import { logAudit } from '@/db/audit-log';import { member } from '@/db/schema/auth';import { withTenant } from '@/db/tenant';import { authedAction } from '@/lib/auth/authed-action';import { err, ok } from '@/lib/result';
// Module-local, NOT exported: a "use server" module may export only async// functions — Next 16.2.7's ensureServerEntryExports rejects a non-function export// (the Zod schema is an object) at runtime, 500-ing the action. Nothing imports the// schema externally, so the action's input shape is the contract.const changeMemberRoleSchema = z.strictObject({ memberId: z.string().min(1), newRole: z.enum(['admin', 'member']),});
// The only role-management action this project ships (no remove/leave/transfer).// 'owner' is not a settable value — promotion to owner is the transfer flow, not// built. Owner targets are refused, the last owner doubly so. The role change and its// audit row co-transact in one withTenant: if the audit insert fails the whole tx// rolls back — a role changed with no audit row is the wrong direction for a// compliance table. The write goes through tx directly, never the plugin API (whose// after hooks run post-commit, breaking the one-transaction audit contract).export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, newRole }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: eq(member.id, memberId), }); if (!target) { return err('not_found', 'That member is no longer in this organization.'); }
if (target.role === 'owner') { const owners = await ctx.db.query.member.findMany({ where: eq(member.role, 'owner'), }); if (owners.length <= 1) { return err('conflict', 'You cannot change the role of the last owner.'); } return err( 'conflict', "An owner's role is changed through ownership transfer, not here.", ); }
await withTenant(ctx.orgId, async (tx) => { await tx .update(member) .set({ role: newRole }) .where( and(eq(member.id, memberId), eq(member.organizationId, ctx.orgId)), ); await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: target.role, after: newRole }, }); });
revalidatePath('/inspector'); return ok({ memberId, role: newRole }); },);The schema is module-local and not exported: a 'use server' module may export only async functions, and Next 16.2.7’s ensureServerEntryExports 500s a non-function export at runtime. memberId is z.string().min(1), not uuid, because Better Auth member ids are base62 text; newRole omits 'owner', so promotion to owner is rejected as a validation error before the body runs.
'use server';
import { and, eq } from 'drizzle-orm';import { revalidatePath } from 'next/cache';import { z } from 'zod';
import { logAudit } from '@/db/audit-log';import { member } from '@/db/schema/auth';import { withTenant } from '@/db/tenant';import { authedAction } from '@/lib/auth/authed-action';import { err, ok } from '@/lib/result';
// Module-local, NOT exported: a "use server" module may export only async// functions — Next 16.2.7's ensureServerEntryExports rejects a non-function export// (the Zod schema is an object) at runtime, 500-ing the action. Nothing imports the// schema externally, so the action's input shape is the contract.const changeMemberRoleSchema = z.strictObject({ memberId: z.string().min(1), newRole: z.enum(['admin', 'member']),});
// The only role-management action this project ships (no remove/leave/transfer).// 'owner' is not a settable value — promotion to owner is the transfer flow, not// built. Owner targets are refused, the last owner doubly so. The role change and its// audit row co-transact in one withTenant: if the audit insert fails the whole tx// rolls back — a role changed with no audit row is the wrong direction for a// compliance table. The write goes through tx directly, never the plugin API (whose// after hooks run post-commit, breaking the one-transaction audit contract).export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, newRole }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: eq(member.id, memberId), }); if (!target) { return err('not_found', 'That member is no longer in this organization.'); }
if (target.role === 'owner') { const owners = await ctx.db.query.member.findMany({ where: eq(member.role, 'owner'), }); if (owners.length <= 1) { return err('conflict', 'You cannot change the role of the last owner.'); } return err( 'conflict', "An owner's role is changed through ownership transfer, not here.", ); }
await withTenant(ctx.orgId, async (tx) => { await tx .update(member) .set({ role: newRole }) .where( and(eq(member.id, memberId), eq(member.organizationId, ctx.orgId)), ); await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: target.role, after: newRole }, }); });
revalidatePath('/inspector'); return ok({ memberId, role: newRole }); },);The wrapper has already resolved the caller, refused anyone below admin, and parsed the form. The body reads the target through ctx.db — the scoped facade — and returns not_found if the member is gone.
'use server';
import { and, eq } from 'drizzle-orm';import { revalidatePath } from 'next/cache';import { z } from 'zod';
import { logAudit } from '@/db/audit-log';import { member } from '@/db/schema/auth';import { withTenant } from '@/db/tenant';import { authedAction } from '@/lib/auth/authed-action';import { err, ok } from '@/lib/result';
// Module-local, NOT exported: a "use server" module may export only async// functions — Next 16.2.7's ensureServerEntryExports rejects a non-function export// (the Zod schema is an object) at runtime, 500-ing the action. Nothing imports the// schema externally, so the action's input shape is the contract.const changeMemberRoleSchema = z.strictObject({ memberId: z.string().min(1), newRole: z.enum(['admin', 'member']),});
// The only role-management action this project ships (no remove/leave/transfer).// 'owner' is not a settable value — promotion to owner is the transfer flow, not// built. Owner targets are refused, the last owner doubly so. The role change and its// audit row co-transact in one withTenant: if the audit insert fails the whole tx// rolls back — a role changed with no audit row is the wrong direction for a// compliance table. The write goes through tx directly, never the plugin API (whose// after hooks run post-commit, breaking the one-transaction audit contract).export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, newRole }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: eq(member.id, memberId), }); if (!target) { return err('not_found', 'That member is no longer in this organization.'); }
if (target.role === 'owner') { const owners = await ctx.db.query.member.findMany({ where: eq(member.role, 'owner'), }); if (owners.length <= 1) { return err('conflict', 'You cannot change the role of the last owner.'); } return err( 'conflict', "An owner's role is changed through ownership transfer, not here.", ); }
await withTenant(ctx.orgId, async (tx) => { await tx .update(member) .set({ role: newRole }) .where( and(eq(member.id, memberId), eq(member.organizationId, ctx.orgId)), ); await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: target.role, after: newRole }, }); });
revalidatePath('/inspector'); return ok({ memberId, role: newRole }); },);The owner guard. If the target is an owner, it counts the org’s owners through the same facade: at one or fewer it returns the last-owner conflict, otherwise the generic owner-target conflict. Both are refusals returned before any write.
'use server';
import { and, eq } from 'drizzle-orm';import { revalidatePath } from 'next/cache';import { z } from 'zod';
import { logAudit } from '@/db/audit-log';import { member } from '@/db/schema/auth';import { withTenant } from '@/db/tenant';import { authedAction } from '@/lib/auth/authed-action';import { err, ok } from '@/lib/result';
// Module-local, NOT exported: a "use server" module may export only async// functions — Next 16.2.7's ensureServerEntryExports rejects a non-function export// (the Zod schema is an object) at runtime, 500-ing the action. Nothing imports the// schema externally, so the action's input shape is the contract.const changeMemberRoleSchema = z.strictObject({ memberId: z.string().min(1), newRole: z.enum(['admin', 'member']),});
// The only role-management action this project ships (no remove/leave/transfer).// 'owner' is not a settable value — promotion to owner is the transfer flow, not// built. Owner targets are refused, the last owner doubly so. The role change and its// audit row co-transact in one withTenant: if the audit insert fails the whole tx// rolls back — a role changed with no audit row is the wrong direction for a// compliance table. The write goes through tx directly, never the plugin API (whose// after hooks run post-commit, breaking the one-transaction audit contract).export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, newRole }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: eq(member.id, memberId), }); if (!target) { return err('not_found', 'That member is no longer in this organization.'); }
if (target.role === 'owner') { const owners = await ctx.db.query.member.findMany({ where: eq(member.role, 'owner'), }); if (owners.length <= 1) { return err('conflict', 'You cannot change the role of the last owner.'); } return err( 'conflict', "An owner's role is changed through ownership transfer, not here.", ); }
await withTenant(ctx.orgId, async (tx) => { await tx .update(member) .set({ role: newRole }) .where( and(eq(member.id, memberId), eq(member.organizationId, ctx.orgId)), ); await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: target.role, after: newRole }, }); });
revalidatePath('/inspector'); return ok({ memberId, role: newRole }); },);The role update and the member.role-changed audit row co-transact in one withTenant, so a failure in the audit insert rolls the update back with it. It is withTenant, not ctx.db.transaction, because only withTenant runs set_config('app.org_id', …), which the audit_logs RLS policy requires for the INSERT to clear.
'use server';
import { and, eq } from 'drizzle-orm';import { revalidatePath } from 'next/cache';import { z } from 'zod';
import { logAudit } from '@/db/audit-log';import { member } from '@/db/schema/auth';import { withTenant } from '@/db/tenant';import { authedAction } from '@/lib/auth/authed-action';import { err, ok } from '@/lib/result';
// Module-local, NOT exported: a "use server" module may export only async// functions — Next 16.2.7's ensureServerEntryExports rejects a non-function export// (the Zod schema is an object) at runtime, 500-ing the action. Nothing imports the// schema externally, so the action's input shape is the contract.const changeMemberRoleSchema = z.strictObject({ memberId: z.string().min(1), newRole: z.enum(['admin', 'member']),});
// The only role-management action this project ships (no remove/leave/transfer).// 'owner' is not a settable value — promotion to owner is the transfer flow, not// built. Owner targets are refused, the last owner doubly so. The role change and its// audit row co-transact in one withTenant: if the audit insert fails the whole tx// rolls back — a role changed with no audit row is the wrong direction for a// compliance table. The write goes through tx directly, never the plugin API (whose// after hooks run post-commit, breaking the one-transaction audit contract).export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, newRole }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: eq(member.id, memberId), }); if (!target) { return err('not_found', 'That member is no longer in this organization.'); }
if (target.role === 'owner') { const owners = await ctx.db.query.member.findMany({ where: eq(member.role, 'owner'), }); if (owners.length <= 1) { return err('conflict', 'You cannot change the role of the last owner.'); } return err( 'conflict', "An owner's role is changed through ownership transfer, not here.", ); }
await withTenant(ctx.orgId, async (tx) => { await tx .update(member) .set({ role: newRole }) .where( and(eq(member.id, memberId), eq(member.organizationId, ctx.orgId)), ); await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: target.role, after: newRole }, }); });
revalidatePath('/inspector'); return ok({ memberId, role: newRole }); },);After the transaction commits, revalidatePath('/inspector') refreshes the panels and the action returns ok({ memberId, role: newRole }). Every rejected path returns before withTenant is ever called, so a rejected attempt can never leave an audit row behind.
The single-owner rule comes from chapter 057, and withTenant and logAudit from last lesson. Their co-transaction is also why the untested requirement holds: every rejected path returns before withTenant is called, so the auditLogs count moves only on a committed write, never behind a refusal.
Reference for the findMany/findFirst and with: { user: true } join the facade wraps — and why the type cast preserves it.
The owner/admin/member roles, member ids, and updateRole this action operates on all come from this plugin.
How pgPolicy and roles back the RLS-guarded audit write that withTenant — not the facade — reaches for.
The USING / WITH CHECK semantics behind the current_setting('app.org_id') policy guarding audit_logs inserts.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 4It should pass. The suite imports your tenantDb, authedAction, and changeMemberRole, then runs them against the live Docker Postgres on the dev seed, restoring every row it touches so it stays re-runnable. It checks that tenantDb scopes reads and writes to one org: reads return only that org’s rows, inserts get the organizationId injected, and a mismatched id throws. A compile-time check confirms the query surface exposes only member and invitation, so .query.user fails to type. For changeMemberRole, it confirms each path: a member is forbidden with no write, an invalid newRole fails validation before the body runs, and admin Bob updates Carol’s row and writes one member.role-changed audit entry. Owner targets are refused, the sole owner Alice with the last-owner message. Finally, a forced audit-write failure proves the role update rolls back with it.
A few things the tests can’t reach, you confirm by hand in the inspector. Use the acting-user switcher to change who you are, then submit role changes from the members panel.
admin: her row updates and the audit tail shows a member.role-changed entry attributed to Bob.forbidden result renders inline, with no DB change and no audit row — the <Select> is still shown to Carol, and the server is what refuses.conflict renders, and because Alice is Acme’s sole owner it carries the last-owner message; the database is unchanged.auditLogs count increments only after the successful change — not after either rejected attempt.The invite flow doesn’t exist yet: the invite form does nothing, and the accept page can’t load a real invitation. Building it, the signed accept link, the token hash, and the email sent after the transaction commits, is the next lesson.