dimah-formv0.6.1
Server

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*.

Authorized write
  1. GuardReject early
  2. ValidateSnapshot
  3. on*Set ownership
  4. StorePersist
  5. 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.

lib/form.ts
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.

lib/form.ts
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.

app/page.tsx
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:

lib/form-client.ts
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

Next

On this page