Security
Authorize with guard, set ownership in hooks, and run side effects safely.
dimah-form does not ship sessions or roles. Your application authorizes each
operation in guard, sets trusted ownership in onStart, and runs side effects
in after*.
- GuardReject early
- ValidateSnapshot
- on*Set ownership
- StorePersist
- after*Side effects
Authorize requests
guard runs before form logic. Throw an APIError with a stable code to reject
the request. Use getResponse() and getForm() for ownership checks; they read
the store and do not call HTTP or re-enter guard.
import {
APIError,
FORM_ERROR_CODES,
dimahForm,
memoryAdapter,
} from "@dimah-form/server";
export const form = dimahForm({
database: memoryAdapter(),
forms,
guard: async ({ request, operation, responseId, getResponse }) => {
const session = await getSession(request);
if (!session) {
throw APIError.from("UNAUTHORIZED", FORM_ERROR_CODES.UNAUTHORIZED);
}
if (operation === "listResponses" && session.user.role !== "admin") {
throw APIError.from("FORBIDDEN", FORM_ERROR_CODES.FORBIDDEN);
}
if (!responseId) return;
const response = await getResponse(responseId);
if (response?.respondentId !== session.user.id) {
throw APIError.from("FORBIDDEN", FORM_ERROR_CODES.FORBIDDEN);
}
},
});Plugin endpoints use their endpoint key as operation, so apply the same
authorization policy to them.
Trust the server, not the browser
Set respondentId after validation and before persistence. Do not accept an
owner sent from client code.
export const form = dimahForm({
database,
hooks: {
onStart: async ({ request, response }) => {
const session = await getSession(request);
if (session) response.respondentId = session.user.id;
},
afterSubmit: async ({ response }) => {
await sendReceipt(response);
},
},
});on* hooks run after validation and before the write; throwing aborts it.
after* hooks run after a successful write. An after* failure can make the
HTTP request fail even though the row is already stored, so design side effects
to be idempotent.
onStart stamps newly created rows. When resume: true finds an existing
draft, that row is returned as-is, so the respondent id used for lookup must
already be trusted.
Resume authenticated drafts
Resolve authenticated ownership on the server. Call form.api.startResponse
with the respondent id from the session, then pass the returned response to
useFormResponse. This keeps the lookup key out of browser control.
import { headers } from "next/headers";
import { form } from "@/lib/form";
const session = await requireSession();
const response = await form.api.startResponse({
body: {
formId: "feedback",
respondentId: session.user.id,
resume: true,
},
headers: await headers(),
});For an anonymous flow, use an app-issued, unguessable resume token and treat it as a credential. Never use an email address or another predictable identifier as a public resume key.
Browser credentials
Configure the client for your own session scheme:
export const formClient = createFormClient<Form>({
basePath: "/api/form",
credentials: "include",
headers: async () => {
const token = await getClientToken();
return token ? { Authorization: `Bearer ${token}` } : {};
},
});form.api has no browser context; pass request headers explicitly when its
guard needs them.
Hook context
import type {
DimahFormGuardContext,
DimahFormHooks,
ResponseHookContext,
} from "@dimah-form/server";Prop
Type
Prop
Type
Prop
Type