dimah-survey
Build the backend

Authorization and identity

Guard fill and editor independently, then establish response ownership on the server.

dimah-survey has no session, account, or role system. Your application authorizes requests in guard; the library turns the result into response ownership and audience isolation.

Fill request
  1. GuardReturn a principal
  2. StampRespondent id
  3. ValidateSnapshot
  4. StorePersist

Isolate the audiences

AudienceGuard contractAccess
FillReturn { respondentId } or { anonymous: true }Published read and response lifecycle
EditorReturn nothing; throw to rejectAuthoring, publish, settings, and full response reads

A fill instance requires guard. Returning nothing from a fill guard is invalid. An editor guard must not return a principal. Keep the routes and instances separate even when both use the same store.

getPublishedSurvey is on the fill handler. It does not stamp a respondent and does not require a response id. The editor draft is not on that route.

Only editor may request include: "full" and read response definitions and data in bulk. Fill owns response mutations. Submit hooks and validateResult therefore belong to fill; publish hooks belong to editor.

Stamp an identified respondent

guardRespondent(id) returns { respondentId }. The server then:

  • stamps that id onto startResponse and listResponses
  • rejects a different id in the body or query
  • rejects include: "full"
  • rejects get, partial save, submit, abandon, and reopen when the stored respondentId differs
lib/survey.ts
import {
  APIError,
  SURVEY_ERROR_CODES,
  dimahSurvey,
  guardRespondent,
} from "@dimah-survey/server";

export const fill = dimahSurvey({
  audience: "fill",
  database,
  guard: async (context) => {
    const session = await getSession(context.request);
    if (!session) {
      throw APIError.from("FORBIDDEN", SURVEY_ERROR_CODES.FORBIDDEN);
    }
    return guardRespondent(session.user.id)(context);
  },
});

The browser never chooses ownership. A supplied respondentId must match the guard; omitting it still stamps the server-established id.

Identified start follows settings.responses. See Settings.

Treat anonymous ids as capabilities

guardAnonymous() returns { anonymous: true }. Each start inserts a row. Listing and a body respondentId are refused. The response id itself becomes the capability for read and mutation, and the row must have a null respondentId.

lib/survey.ts
import { guardAnonymous, guardRespondent } from "@dimah-survey/server";

export const fill = dimahSurvey({
  audience: "fill",
  database,
  guard: (context) => {
    const userId = userIdFromSession(context.request);
    if (!userId) return guardAnonymous()(context);
    return guardRespondent(userId)(context);
  },
});

Store and transmit an anonymous response id like a secret. Anyone who has it can read and mutate that response.

Protect the editor route

lib/survey.ts
import { APIError, SURVEY_ERROR_CODES, dimahSurvey } from "@dimah-survey/server";

export const editor = dimahSurvey({
  audience: "editor",
  database,
  guard: ({ request }) => {
    if (!isEditor(request)) {
      throw APIError.from("FORBIDDEN", SURVEY_ERROR_CODES.FORBIDDEN);
    }
  },
});

Returning a principal from this guard is an error. Editor authorization only allows the request or throws.

include: "full" belongs here. It returns definition and data for analytics. Do not expose that route on the fill path.

Place side effects deliberately

onStart, onSubmit, and onPublish may abort their write. Their after* counterparts run after storage; throwing there does not roll the row back. Email, webhooks, and queue work generally belong in after* callbacks and must be safe to retry at the application boundary.

A successful idempotent resubmit does not run submit hooks again. resumeSurvey does not run publish hooks.

On this page