@murumets-ee/forms-ui 0.101.0 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":["MAX_BODY_BYTES","json","isRecord","message","exportCopy","csvTime","exportRoute"],"sources":["../src/privacy-routes.ts","../src/submission-routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * The Privacy requests screen's admin API (forms D007, W5) — prefix\n * `form-privacy`:\n *\n * - `POST form-privacy/search` `{ email }` — which of this site's submissions\n * carry that address: form, time and page of each, and the total.\n * - `POST form-privacy/export` `{ email, format, uiLocale? }` — all of them,\n * with their answers under the labels they had, as CSV or JSON.\n * - `DELETE form-privacy` `{ email }` — erase all of them, for good.\n *\n * **The address travels in the request BODY, never the URL** — which is why\n * the two reads are POSTs. A query string is written to access logs, proxy\n * logs and browser history; an address there would be personal data in a log\n * (C5), about the very person asking to be forgotten.\n *\n * **Authorization.** Each method declares its permission, checked by\n * `defineAdminRoute` before the handler runs: finding and exporting is\n * `form_submissions:view`, erasing is `form_submissions:manage` (forms D011).\n * The rows are then reached through `@murumets-ee/forms`' privacy functions,\n * which use an elevated client — sanctioned because the route HAS authorized,\n * on a different resource, and nothing from the body is forwarded into a\n * write: the one input is an address, parsed to a whole address and used as a\n * bound parameter of a lookup. The site is the request's, so another site's\n * submissions are never found (C2).\n *\n * **Erasing names the address again.** The erase takes `{ email }`, not ids\n * from the browser, so what is erased is what the server finds for that\n * address at that moment — a submission that arrived since the search goes\n * too, and a browser cannot name rows of its own choosing.\n *\n * **Agents.** An agent whose role holds the permission may FIND and EXPORT\n * (`tokens: 'accept'`), as it may read a form's Submissions. ERASING is closed\n * to every token-derived door (no `tokens` choice, which is deny): an erase a\n * person has to approve is PARKED with its request body, and that body is the\n * address — stored in the approvals table, and shown on the Approvals page,\n * about the very person asking to be forgotten. A person erases; an agent can\n * prepare the request by finding and exporting.\n *\n * **What a search cannot see.** A submission stored before the email lookup\n * existed is not findable until the nightly clean-up has filled its lookup\n * in. Search and erase answer `unindexed` — how many of this site's\n * submissions that is — and the screen says so, so \"nothing found\" is never\n * mistaken for \"nothing stored\".\n *\n * **Audit (C5).** A row names the action, a count and the form ids. Never the\n * address, never an answer.\n */\n\nimport {\n type CsvExport,\n csvDelimiterFor,\n csvFilename,\n pagedExportRows,\n runCsvExport,\n} from '@murumets-ee/admin-ui/csv-export'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n countSubmissionsByEmail,\n countUnindexedSubmissions,\n eraseSubmissionsByEmail,\n FORM_SUBMISSIONS_MANAGE,\n FORM_SUBMISSIONS_VIEW,\n findSubmissionsByEmail,\n formNames,\n type PersonSubmission,\n PRIVACY_EXPORT_MAX,\n PRIVACY_LIST_MAX,\n parsePrivacyEmail,\n readCappedBody,\n SUBMISSIONS_READ_MAX,\n type SubmissionAnswer,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\nimport type { PrivacyMatch } from './privacy-match.js'\n\nexport const PRIVACY_ROUTES_PREFIX = 'form-privacy'\n\n/** Says whether an export stopped at its row cap — read by the screen. */\nexport const TRUNCATED_HEADER = 'X-Lumi-Export-Truncated'\n\n/** Exports being READ into memory at once in this process; a third is told to wait. */\nconst MAX_EXPORT_READS = 2\nlet reading = 0\n\n/** For tests: how many privacy exports are being read right now. */\nexport function _readingPrivacyExports(): number {\n return reading\n}\n\nfunction busy(): Response {\n return new Response(JSON.stringify({ error: 'export_busy', code: 'export_busy' }), {\n status: 503,\n headers: {\n 'Content-Type': 'application/json',\n 'Cache-Control': 'no-store',\n 'Retry-After': '10',\n },\n })\n}\n\n/** A body is an address and two short words. */\nconst MAX_BODY_BYTES = 2048\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The body's address, with exactly the keys named beside it — strict, so a\n * body carrying anything else is malformed rather than half-read.\n */\nasync function readRequest(\n req: Request,\n extraKeys: readonly string[],\n): Promise<{ email: string; rest: Record<string, unknown> } | { refusal: Response }> {\n // Capped as the bytes arrive — a header can lie or be absent.\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n let value: unknown\n try {\n value = JSON.parse(body.text)\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n const allowed = new Set(['email', ...extraKeys])\n if (!isRecord(value) || Object.keys(value).some((key) => !allowed.has(key))) {\n return { refusal: json({ error: 'invalid_body' }, 400) }\n }\n const email = parsePrivacyEmail(value.email)\n // The answer never echoes what was sent.\n if (email === null)\n return { refusal: json({ error: 'invalid_email', code: 'invalid_email' }, 400) }\n return { email, rest: value }\n}\n\n// ---------------------------------------------------------------------------\n// POST search — what the screen lists\n// ---------------------------------------------------------------------------\n\nexport const handlePrivacySearch: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, [])\n if ('refusal' in request) return request.refusal\n const { email } = request\n const [total, rows, unindexed] = await Promise.all([\n countSubmissionsByEmail(ctx.app, email),\n findSubmissionsByEmail(ctx.app, email, { limit: PRIVACY_LIST_MAX, offset: 0 }),\n countUnindexedSubmissions(ctx.app),\n ])\n const names = await formNames(\n ctx.app,\n rows.map((row) => row.formId),\n )\n const items: PrivacyMatch[] = rows.map((row) => ({\n id: row.id,\n formId: row.formId,\n formName: names.get(row.formId) ?? null,\n createdAt: row.createdAt,\n pagePath: row.pagePath,\n }))\n ctx.audit?.({\n action: 'forms.privacy.search',\n entityType: 'form_submission',\n metadata: { count: total, forms: [...new Set(rows.map((row) => row.formId))], unindexed },\n })\n return json({ total, items, unindexed, exportMax: PRIVACY_EXPORT_MAX })\n}\n\n// ---------------------------------------------------------------------------\n// POST export — everything the person sent, with labels\n// ---------------------------------------------------------------------------\n\nconst FORMATS = ['csv', 'json'] as const\ntype ExportFormat = (typeof FORMATS)[number]\n\ninterface ExportCopy {\n form: string\n received: string\n page: string\n status: string\n yes: string\n no: string\n deletedForm: string\n statuses: Record<string, string>\n}\n\n/** `Forms.<...path>` in a catalogue, when it is a non-empty string. */\nfunction message(catalogue: Record<string, unknown>, path: readonly string[]): string | undefined {\n let node: unknown = catalogue.Forms\n for (const key of path) {\n if (!isRecord(node)) return undefined\n node = node[key]\n }\n return typeof node === 'string' && node !== '' ? node : undefined\n}\n\nasync function exportCopy(locale: string | undefined): Promise<ExportCopy> {\n // The loader answers English for a locale it does not ship, so an unknown\n // `uiLocale` cannot choose a file to import.\n const catalogue = await getFormsMessages(locale ?? 'en')\n const pick = (path: readonly string[], fallback: string) => message(catalogue, path) ?? fallback\n return {\n form: pick(['privacy', 'csv', 'form'], 'Form'),\n received: pick(['privacy', 'csv', 'received'], 'Received (UTC)'),\n page: pick(['privacy', 'csv', 'page'], 'Page'),\n status: pick(['privacy', 'csv', 'status'], 'Status'),\n yes: pick(['submissions', 'yes'], 'yes'),\n no: pick(['submissions', 'no'], 'no'),\n deletedForm: pick(['privacy', 'deletedForm'], 'deleted'),\n statuses: {\n new: pick(['submissionStatus', 'new'], 'New'),\n read: pick(['submissionStatus', 'read'], 'Read'),\n archived: pick(['submissionStatus', 'archived'], 'Archived'),\n },\n }\n}\n\n/** The label an answer had when it was sent; its key when the snapshot has none. */\nfunction labelOf(row: PersonSubmission, key: string): string {\n const label = row.fields[key]?.label\n return label !== undefined && label !== '' ? label : key\n}\n\n/** `{ label: answers }` of one submission — two fields that shared a label share its cell. */\nfunction answersByLabel(row: PersonSubmission): Map<string, SubmissionAnswer[]> {\n const byLabel = new Map<string, SubmissionAnswer[]>()\n for (const [key, value] of Object.entries(row.data)) {\n const label = labelOf(row, key)\n byLabel.set(label, [...(byLabel.get(label) ?? []), value])\n }\n return byLabel\n}\n\nfunction cell(values: readonly SubmissionAnswer[] | undefined, copy: ExportCopy): string | number {\n if (values === undefined || values.length === 0) return ''\n const words = values.map((value) =>\n typeof value === 'boolean' ? (value ? copy.yes : copy.no) : value,\n )\n const only = words[0]\n return words.length === 1 && only !== undefined ? only : words.join(' | ')\n}\n\n/** `2026-10-06 14:02` — a form a spreadsheet reads as a date, in UTC as the header says. */\nfunction csvTime(iso: string): string {\n return iso.slice(0, 16).replace('T', ' ')\n}\n\n/** The person's submissions, page by page, fixed at `until`, at most the export cap. */\nfunction personRows(\n app: ToolkitApp,\n email: string,\n until: Date,\n onTruncated?: (written: number) => void,\n): AsyncGenerator<PersonSubmission, void, undefined> {\n return pagedExportRows<PersonSubmission>(\n (offset, limit) => findSubmissionsByEmail(app, email, { limit, offset, until }),\n {\n pageSize: SUBMISSIONS_READ_MAX,\n maxRows: PRIVACY_EXPORT_MAX,\n ...(onTruncated !== undefined && { onTruncated }),\n },\n )\n}\n\nexport const handlePrivacyExport: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, ['format', 'uiLocale'])\n if ('refusal' in request) return request.refusal\n const { email, rest } = request\n const format: ExportFormat | undefined = FORMATS.find((name) => name === rest.format)\n if (format === undefined) return json({ error: 'invalid_format' }, 400)\n const uiLocale =\n typeof rest.uiLocale === 'string' && rest.uiLocale.length <= 35 ? rest.uiLocale : ctx.locale\n const app = ctx.app\n // The whole set is read ONCE, into memory, before a byte is written: the CSV\n // header needs every label, and a second pass over a set that an erase or\n // the clean-up is changing could meet a row the first pass never saw. So the\n // read is bounded twice — PRIVACY_EXPORT_MAX rows, and MAX_EXPORT_READS\n // reads at a time in this process. (The rows then live until the download\n // ends: what is bounded is the querying, and each file's size.)\n if (reading >= MAX_EXPORT_READS) return busy()\n reading += 1\n const until = new Date()\n const rows: PersonSubmission[] = []\n let truncated = false\n let names: Map<string, string>\n try {\n for await (const row of personRows(app, email, until, () => {\n truncated = true\n })) {\n rows.push(row)\n }\n names = await formNames(app, [...new Set(rows.map((row) => row.formId))])\n } finally {\n reading -= 1\n }\n if (truncated) {\n // A count — nothing a visitor sent, and not the address.\n app.logger.warn(\n { written: rows.length },\n 'forms: a privacy export reached the row cap — the file holds the newest rows only',\n )\n }\n // Written once the file is actually on its way — a refused export (every CSV\n // slot taken) must not be recorded as one somebody received.\n const audit = (): void => {\n ctx.audit?.({\n action: 'forms.privacy.export',\n entityType: 'form_submission',\n metadata: {\n count: rows.length,\n forms: [...new Set(rows.map((row) => row.formId))],\n format,\n truncated,\n },\n })\n }\n\n if (format === 'json') {\n const body = JSON.stringify(\n {\n exportedAt: until.toISOString(),\n truncated,\n submissions: rows.map((row) => ({\n form: names.get(row.formId) ?? null,\n receivedAt: row.createdAt,\n page: row.pagePath,\n language: row.locale,\n status: row.status,\n answers: Object.entries(row.data).map(([key, value]) => ({\n field: labelOf(row, key),\n key,\n answer: value,\n })),\n consent: Object.values(row.consent),\n })),\n },\n null,\n 2,\n )\n audit()\n return new Response(body, {\n status: 200,\n headers: {\n 'Content-Type': 'application/json; charset=utf-8',\n 'Content-Disposition': `attachment; filename=\"${csvFilename('privacy-request').replace(/\\.csv$/, '.json')}\"`,\n 'Cache-Control': 'private, no-store',\n 'X-Content-Type-Options': 'nosniff',\n [TRUNCATED_HEADER]: String(truncated),\n },\n })\n }\n\n const copy = await exportCopy(uiLocale)\n const labels: string[] = []\n const seen = new Set<string>()\n for (const row of rows) {\n for (const key of Object.keys(row.data)) {\n const label = labelOf(row, key)\n if (seen.has(label)) continue\n seen.add(label)\n labels.push(label)\n }\n }\n const res = await runCsvExport(\n (): CsvExport => ({\n filename: csvFilename('privacy-request'),\n delimiter: csvDelimiterFor(uiLocale),\n header: [copy.form, copy.received, copy.page, copy.status, ...labels],\n rows: rows.map((row) => {\n const answers = answersByLabel(row)\n return [\n names.get(row.formId) ?? copy.deletedForm,\n csvTime(row.createdAt),\n row.pagePath ?? '',\n copy.statuses[row.status] ?? row.status,\n ...labels.map((label) => cell(answers.get(label), copy)),\n ]\n }),\n }),\n )\n if (res.ok) {\n audit()\n res.headers.set(TRUNCATED_HEADER, String(truncated))\n }\n return res\n}\n\n// ---------------------------------------------------------------------------\n// DELETE — erase, for good\n// ---------------------------------------------------------------------------\n\nexport const handlePrivacyErase: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, [])\n if ('refusal' in request) return request.refusal\n // Never throws part-way: what went before a failed batch is reported, so it\n // reaches the audit row below either way.\n const result = await eraseSubmissionsByEmail(ctx.app, request.email)\n const unindexed = await countUnindexedSubmissions(ctx.app).catch(() => null)\n ctx.audit?.({\n action: 'forms.privacy.erase',\n entityType: 'form_submission',\n // How many, from which forms — never whose (D007).\n metadata: {\n count: result.erased,\n forms: [...result.formIds],\n unfinished: result.more,\n failed: result.failed,\n unindexed,\n },\n })\n return json(\n {\n erased: result.erased,\n forms: result.formIds.length,\n more: result.more,\n failed: result.failed,\n unindexed,\n },\n result.failed ? 500 : 200,\n )\n}\n\n// ---------------------------------------------------------------------------\n// Declarations\n// ---------------------------------------------------------------------------\n\nconst searchRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: 'search',\n method: 'POST',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Find this site’s submissions by one whole email address: { email }.',\n descriptionKey: 'Forms.permissions.routePrivacySearch',\n tokens: 'accept',\n handler: handlePrivacySearch,\n})\n\nconst exportRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: 'export',\n method: 'POST',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Download one person’s submissions as CSV or JSON: { email, format }.',\n descriptionKey: 'Forms.permissions.routePrivacyExport',\n tokens: 'accept',\n handler: handlePrivacyExport,\n})\n\nconst eraseRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: '',\n method: 'DELETE',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Erase one person’s submissions for good: { email }. People only, no agents.',\n descriptionKey: 'Forms.permissions.routePrivacyErase',\n // Closed to every token-derived door, and said so (agent-access PR14). A\n // parked erase would store its body — the address — in the approvals table\n // (see the header). A person erases, from their own session.\n tokens: { refuse: 'personal-data' },\n handler: handlePrivacyErase,\n})\n\n/** The Privacy requests screen's admin routes (`server.routes`). */\nexport function privacyRoutes(): AdminRoute[] {\n return combineAdminRoutes([searchRoute, exportRoute, eraseRoute])\n}\n","/**\n * The Submissions screen's admin API (forms D005, W4) — prefix\n * `form-submissions`:\n *\n * - `GET form-submissions/export?form=<id>&…filters` — the filtered set as a CSV.\n * - `GET form-submissions/summary?form=<id>` — `{ total, fresh }`, for the\n * \"Submissions (3 new)\" link on a Form block's panel (W3).\n * - `PATCH form-submissions` `{ form, ids, status }` — mark new / read / archived.\n * - `DELETE form-submissions` `{ form, ids }` — delete for good.\n *\n * (The screen's own rows are read on the server by the page; there is no list\n * route to keep in step with it.)\n *\n * **Authorization.** Each method declares its permission, checked by\n * `defineAdminRoute` before the handler runs: reading and exporting is\n * `form_submissions:view`, changing and deleting is `form_submissions:manage`.\n * The rows are then reached through `@murumets-ee/forms`' submission functions,\n * which use an elevated client — sanctioned here because the route HAS\n * authorized, on a different resource, and because nothing from the request\n * body is forwarded into a write: a status is one of three literals and ids\n * are UUIDs, both validated below. The site is the request's, and every query\n * names the form, so another site's form id reads as \"not found\" (C2).\n *\n * **Agents.** An agent whose role holds the permission may use these routes\n * (`tokens: 'accept'`), as it may the support inbox (agent-access D021);\n * deleting is `confirm: true`, so an agent under a human-approves policy has\n * its delete parked for a person.\n *\n * **Personal data (C5).** An audit row names the form, the action and a count.\n * Never an answer.\n *\n * Lives in forms-ui, not `@murumets-ee/forms`, because the export answers\n * through admin-ui's CSV writer and the forms package does not depend on\n * admin-ui. Reached from the jiti-loaded plugin entry, so nothing here imports\n * a `server-only` module.\n */\n\nimport {\n type CsvExport,\n csvDelimiterFor,\n csvFilename,\n pagedExportRows,\n runCsvExport,\n} from '@murumets-ee/admin-ui/csv-export'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n collectRetiredColumns,\n currentFormFields,\n deleteSubmissions,\n FORM_SUBMISSIONS_MANAGE,\n FORM_SUBMISSIONS_VIEW,\n Form,\n isSubmissionStatus,\n listSubmissionRows,\n MAX_SUBMISSION_BULK,\n parseSubmissionFilters,\n readCappedBody,\n SUBMISSIONS_READ_MAX,\n type SubmissionAnswer,\n type SubmissionRow,\n setSubmissionStatus,\n submissionColumns,\n submissionCounts,\n submissionFilterParams,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\n\nexport const SUBMISSION_ROUTES_PREFIX = 'form-submissions'\n\n/** A body is a form id, a status and at most a hundred ids. */\nconst MAX_BODY_BYTES = 8192\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** `Array.isArray` on `unknown` narrows to `any[]`; this keeps the elements `unknown`. */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * `{ form, ids, ...extra }` with exactly the keys named — strict, so a body\n * carrying anything else is malformed rather than half-read. `null` otherwise.\n */\nfunction parseTarget(\n value: unknown,\n extraKeys: readonly string[],\n): { form: string; ids: string[]; rest: Record<string, unknown> } | null {\n if (!isRecord(value)) return null\n const allowed = new Set(['form', 'ids', ...extraKeys])\n if (Object.keys(value).some((key) => !allowed.has(key))) return null\n const { form, ids } = value\n if (typeof form !== 'string' || !UUID_RE.test(form)) return null\n if (!isUnknownArray(ids) || ids.length === 0 || ids.length > MAX_SUBMISSION_BULK) return null\n const clean = ids.filter((id): id is string => typeof id === 'string' && UUID_RE.test(id))\n if (clean.length !== ids.length) return null\n return { form, ids: clean, rest: value }\n}\n\n/** The request body as JSON, or the refusal to answer with. */\nasync function readBody(req: Request): Promise<{ value: unknown } | { refusal: Response }> {\n // Capped as the bytes arrive — a header can lie or be absent.\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n const raw = body.text\n try {\n const value: unknown = JSON.parse(raw)\n return { value }\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n}\n\n/**\n * This site's form `id`, or `null`. Read with an elevated client for the same\n * reason the submissions are: the route's own permission is what authorized\n * seeing this form's name and fields, and the site still confines the read —\n * another site's id is not found.\n */\nasync function loadForm(\n app: ToolkitApp,\n id: string,\n): Promise<{ name: string; body: unknown } | null> {\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n if (row === null || row === undefined) return null\n return { name: typeof row.name === 'string' ? row.name : 'form', body: row.body }\n}\n\n// ---------------------------------------------------------------------------\n// GET summary — two numbers, for the link on a Form block's panel\n// ---------------------------------------------------------------------------\n\nexport const handleSummary: AdminRouteHandler = async (req, ctx) => {\n const formId = new URL(req.url).searchParams.get('form') ?? ''\n if (!UUID_RE.test(formId)) return json({ error: 'not_found' }, 404)\n if ((await loadForm(ctx.app, formId)) === null) return json({ error: 'not_found' }, 404)\n const counts = (await submissionCounts(ctx.app, [formId])).get(formId)\n return json({ total: counts?.total ?? 0, fresh: counts?.fresh ?? 0 })\n}\n\n// ---------------------------------------------------------------------------\n// PATCH — mark new / read / archived\n// ---------------------------------------------------------------------------\n\nexport const handleSetStatus: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = parseTarget(body.value, ['status'])\n const status = parsed?.rest.status\n if (parsed === null || !isSubmissionStatus(status)) {\n return json({ error: 'Expected { form: uuid, ids: uuid[], status: new|read|archived }' }, 400)\n }\n const { form } = parsed\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n\n const changed = await setSubmissionStatus(ctx.app, form, parsed.ids, status)\n ctx.audit?.({\n action: 'forms.submissions.status',\n entityType: 'form',\n entityId: form,\n metadata: { status, count: changed },\n })\n return json({ changed })\n}\n\n// ---------------------------------------------------------------------------\n// DELETE — for good\n// ---------------------------------------------------------------------------\n\nexport const handleDelete: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = parseTarget(body.value, [])\n if (parsed === null) return json({ error: 'Expected { form: uuid, ids: uuid[] }' }, 400)\n const { form } = parsed\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n\n const deleted = await deleteSubmissions(ctx.app, form, parsed.ids)\n ctx.audit?.({\n action: 'forms.submissions.delete',\n entityType: 'form',\n entityId: form,\n metadata: { count: deleted },\n })\n return json({ deleted })\n}\n\n// ---------------------------------------------------------------------------\n// GET export — the filtered set as a CSV\n// ---------------------------------------------------------------------------\n\n/** The words a CSV needs, in the reader's interface language (English when the catalogue lacks one). */\ninterface ExportCopy {\n received: string\n page: string\n status: string\n yes: string\n no: string\n removed: string\n statuses: Record<string, string>\n}\n\nconst ENGLISH_COPY: ExportCopy = {\n received: 'Received (UTC)',\n page: 'Page',\n status: 'Status',\n yes: 'yes',\n no: 'no',\n removed: 'removed field',\n statuses: { new: 'New', read: 'Read', archived: 'Archived' },\n}\n\n/** `Forms.<...path>` in a catalogue, when it is a non-empty string. */\nfunction message(catalogue: Record<string, unknown>, path: readonly string[]): string | undefined {\n let node: unknown = catalogue.Forms\n for (const key of path) {\n if (!isRecord(node)) return undefined\n node = node[key]\n }\n return typeof node === 'string' && node !== '' ? node : undefined\n}\n\nasync function exportCopy(locale: string | undefined): Promise<ExportCopy> {\n // The loader answers English for a locale it does not ship, so a hostile or\n // unknown `uiLocale` cannot choose a file to import.\n const catalogue = await getFormsMessages(locale ?? 'en')\n const pick = (path: readonly string[], fallback: string) => message(catalogue, path) ?? fallback\n return {\n received: pick(['submissions', 'csv', 'received'], ENGLISH_COPY.received),\n page: pick(['submissions', 'csv', 'page'], ENGLISH_COPY.page),\n status: pick(['submissions', 'csv', 'status'], ENGLISH_COPY.status),\n yes: pick(['submissions', 'yes'], ENGLISH_COPY.yes),\n no: pick(['submissions', 'no'], ENGLISH_COPY.no),\n removed: pick(['submissions', 'removedField'], ENGLISH_COPY.removed),\n statuses: {\n new: pick(['submissionStatus', 'new'], 'New'),\n read: pick(['submissionStatus', 'read'], 'Read'),\n archived: pick(['submissionStatus', 'archived'], 'Archived'),\n },\n }\n}\n\n/** `2026-10-06 14:02` — a form a spreadsheet reads as a date, in UTC as the header says. */\nfunction csvTime(iso: string): string {\n return iso.slice(0, 16).replace('T', ' ')\n}\n\nfunction csvAnswer(value: SubmissionAnswer | undefined, copy: ExportCopy): string | number {\n if (value === undefined) return ''\n if (typeof value === 'boolean') return value ? copy.yes : copy.no\n return value\n}\n\nexport const handleExport: AdminRouteHandler = async (req, ctx) => {\n const url = new URL(req.url)\n const formId = url.searchParams.get('form') ?? ''\n if (!UUID_RE.test(formId)) return json({ error: 'not_found' }, 404)\n const form = await loadForm(ctx.app, formId)\n if (form === null) return json({ error: 'not_found' }, 404)\n\n // `until`: the set is read twice (below), so it is fixed at the moment the\n // export starts — a submission arriving mid-export is simply in the next one.\n // (A row stamped a moment before this instant and committed a moment after\n // the first pass can still shift one row across a page boundary: the window\n // is the length of an insert, and the cost is one repeated row.)\n const filters = {\n ...parseSubmissionFilters((name) => url.searchParams.get(name)),\n until: new Date(),\n }\n const uiLocale = url.searchParams.get('uiLocale') ?? ctx.locale\n const current = currentFormFields(form.body)\n const app = ctx.app\n const pages = (onTruncated?: (written: number) => void) =>\n pagedExportRows<SubmissionRow>(\n (offset, limit) => listSubmissionRows(app, formId, filters, { limit, offset }),\n // Exactly the reader's own page size: asking for more would be answered\n // with fewer, which reads as \"the last page\".\n { pageSize: SUBMISSIONS_READ_MAX, ...(onTruncated !== undefined && { onTruncated }) },\n )\n\n return runCsvExport(async (): Promise<CsvExport> => {\n ctx.audit?.({\n action: 'forms.submissions.export',\n entityType: 'form',\n entityId: formId,\n metadata: { filtered: submissionFilterParams(filters).size > 0 },\n })\n const copy = await exportCopy(uiLocale)\n // The header has to be written before the first row, and a retired field\n // may first appear on the last page — so the set is read once for its\n // keys, then again for its rows. Both reads are paged and capped.\n const retired = new Map<string, string>()\n for await (const row of pages()) collectRetiredColumns(current, [row], retired)\n const columns = submissionColumns(current, retired)\n\n return {\n filename: csvFilename(form.name),\n delimiter: csvDelimiterFor(uiLocale),\n header: [\n copy.received,\n copy.page,\n copy.status,\n ...columns.map((column) =>\n column.retired ? `${column.label} (${copy.removed})` : column.label,\n ),\n ],\n rows: (async function* () {\n const rows = pages((written) => {\n // The form and a count — nothing a visitor sent.\n app.logger.warn(\n { formId, written },\n 'forms: a submissions export reached the row cap — the file holds the newest rows only',\n )\n })\n for await (const row of rows) {\n yield [\n csvTime(row.createdAt),\n row.pagePath ?? '',\n copy.statuses[row.status] ?? row.status,\n ...columns.map((column) => csvAnswer(row.data[column.key], copy)),\n ]\n }\n })(),\n }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Declarations\n// ---------------------------------------------------------------------------\n\nconst exportRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: 'export',\n method: 'GET',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Download a form’s submissions as CSV: ?form=<id> plus the screen’s filters.',\n descriptionKey: 'Forms.permissions.routeExport',\n tokens: 'accept',\n handler: handleExport,\n})\n\nconst summaryRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: 'summary',\n method: 'GET',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'How many submissions a form holds and how many are new: ?form=<id>.',\n descriptionKey: 'Forms.permissions.routeSummary',\n tokens: 'accept',\n handler: handleSummary,\n})\n\nconst statusRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: '',\n method: 'PATCH',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Mark submissions of a form new, read or archived: { form, ids, status }.',\n descriptionKey: 'Forms.permissions.routeStatus',\n tokens: 'accept',\n handler: handleSetStatus,\n})\n\nconst deleteRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: '',\n method: 'DELETE',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Delete submissions of a form for good: { form, ids }.',\n descriptionKey: 'Forms.permissions.routeDelete',\n tokens: 'accept',\n // Destroys visitors' answers, up to a hundred at a time.\n confirm: true,\n handler: handleDelete,\n})\n\n/** The Submissions screen's admin routes (`server.routes`). */\nexport function submissionRoutes(): AdminRoute[] {\n return combineAdminRoutes([exportRoute, summaryRoute, statusRoute, deleteRoute])\n}\n","/**\n * `formsPlugin()` — everything `@murumets-ee/forms` contributes, declared as\n * data on one Plugin object (CLAUDE.md \"all plugin contributions are\n * declarative\"). Drop it into `plugins:` and the framework wires:\n *\n * - the `form` entity (site-bound canvas document), the `form_submission`\n * entity and the per-form spam count table;\n * - the per-site `forms` settings namespace (CAPTCHA site keys);\n * - the block vocabulary to the SERVER — `blocks.blocks` (so the tree walk\n * that judges every write knows `ancestor` / `uniqueKey`), the `form`\n * environment base (so the canvas resolves its palette), and the Form block\n * in the site's `page` environment (D002 — a saved form on any page);\n * - the admin routes: \"Detach a copy\", \"delete a form with its submissions\",\n * the Submissions screen's (status, delete, CSV export, summary) and\n * Privacy requests' (find by email, export, erase);\n * - the permissions — `forms:manage`, the `form` entity grants and the\n * `form_submissions` row (view / manage) — to the Permission Matrix, seeded\n * for `editor`;\n * - the catalogue (`shared.messages`);\n * - the Forms list, the form canvas, a form's Submissions and Privacy requests\n * as pages, and the sidebar link;\n * - the daily clean-up job that deletes submissions older than their form's\n * period (D007), and the table that says when it last ran.\n *\n * The browser half — renders and the panel's field editors — is\n * `<FormBlocksRegistration />` from `@murumets-ee/forms-ui`, mounted by the\n * admin layout (the standing client-registry exception in CLAUDE.md).\n *\n * jiti-safe: this module is reached from `lumi.config.ts`, so the pages load\n * lazily and nothing here imports JSX or the blocks root.\n */\n\n// The `blocks.*` contribution slots are declared by module augmentation in\n// `@murumets-ee/blocks`; the type-only import is what brings them into scope.\nimport type {} from '@murumets-ee/blocks'\nimport { definePlugin, type PageFactory } from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_VIEW,\n FORMS_MANAGE,\n Form,\n FormSubmission,\n formsFeatures,\n formsRoutes,\n formsSettings,\n formsTables,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\nimport {\n FormEmbedBlock,\n formEnvironment,\n formPageEnvironmentExtension,\n formSchemaBlocks,\n} from '@murumets-ee/forms/schema'\nimport { FormSubmissionsHeaderAction } from '@murumets-ee/forms-ui/canvas-header-action'\nimport { privacyRoutes } from './privacy-routes.js'\nimport { submissionRoutes } from './submission-routes.js'\n\n/** Seeing the Forms list is seeing this site's forms — the `form` entity's own `view`. */\nconst FORM_LIST_GATE = `${Form.name}:view` as const\n\nconst FormsListPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.FormsListPage(props)\n}\nconst FormCanvasPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.FormCanvasPage(props)\n}\nconst SubmissionsPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.SubmissionsPage(props)\n}\n\nconst PrivacyRequestsPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.PrivacyRequestsPage(props)\n}\n\n/** The daily clean-up of expired submissions (forms D007) — see `runFormsRetention`. */\nexport const FORMS_RETENTION_JOB = 'forms:retention'\n\n/** Nightly, off the hour so it does not start with every other 03:00 job. */\nexport const FORMS_RETENTION_SCHEDULE = '23 3 * * *'\n\nexport function formsPlugin() {\n return definePlugin({\n name: '@murumets-ee/forms',\n // `publishable()` keeps its drafts in tables the content plugin owns; the\n // per-site Forms settings (CAPTCHA site keys) live in the settings plugin.\n requires: ['@murumets-ee/content', '@murumets-ee/settings'],\n server: {\n // `form_submission` is written only by the public submission path\n // (`@murumets-ee/forms/server`, mounted on the content API) and read by\n // the submissions screen (PR04).\n entities: [Form, FormSubmission],\n // The per-form \"dropped as spam\" counts (D004) — a number per form.\n // …and, per site, when the daily clean-up last ran and what it removed (D007).\n // One list (`formsTables`), so the erasure test walks exactly these.\n tables: { ...formsTables },\n // `POST /api/admin/form-copy` — the Form block's \"Detach a copy\" —,\n // `DELETE /api/admin/form-remove`, the Submissions screen's routes\n // (`/api/admin/form-submissions`) and Privacy requests'\n // (`/api/admin/form-privacy`).\n routes: [...formsRoutes(), ...submissionRoutes(), ...privacyRoutes()],\n // The daily clean-up. There is no `Plugin.server.jobs` slot to declare\n // into — a job is registered from `init`, as every plugin's is (auth's\n // approvals sweep, the queue's own trash purge); the queue materialises\n // its schedule when a worker starts. Without the queue plugin there is\n // no worker and nothing to register with: the Forms list then says the\n // clean-up has never run, rather than implying a promise is being kept.\n init: async (app) => {\n if (!app.plugins.has('@murumets-ee/queue')) return\n const { defineJob, registerJob } = await import('@murumets-ee/queue/client')\n registerJob(\n // No `description`: the queue catalogue has no translation-key slot,\n // so one would be English on every locale. What it does is above.\n defineJob({ name: FORMS_RETENTION_JOB, schedule: FORMS_RETENTION_SCHEDULE }),\n async () => {\n const { runAsCli, runInEverySite } = await import('@murumets-ee/core')\n const { runFormsRetention } = await import('@murumets-ee/forms')\n // As the synthetic `cli` admin — a job has no signed-in user — and\n // once per site: each run reads and deletes within ONE site. A\n // failure is rethrown so the queue retries and the dead-letter\n // alert fires; a promise to delete must not fail silently.\n const runs = await runAsCli(() =>\n runInEverySite(() => runFormsRetention(app), { app, label: FORMS_RETENTION_JOB }),\n )\n const removed = runs.reduce((sum, run) => sum + run.removed, 0)\n // Counts only — never what was removed (C5).\n app.logger.info(\n {\n sites: runs.length,\n removed,\n unfinished: runs.filter((run) => run.unfinished).length,\n backfilled: runs.reduce((sum, run) => sum + run.backfilled, 0),\n },\n 'forms: daily clean-up of expired submissions finished',\n )\n },\n )\n },\n },\n contributes: {\n // The form document's blocks, and the page's Form block (D002).\n 'blocks.blocks': [...formSchemaBlocks, FormEmbedBlock],\n 'blocks.environmentBases': [formEnvironment],\n // The site's pages may hold a Form block, and the page environment\n // holds the form's blocks too, because a page DRAWS an embedded form\n // with the site theme (see `formPageEnvironmentExtension`).\n 'blocks.environments': formPageEnvironmentExtension,\n },\n shared: {\n features: formsFeatures,\n settings: [formsSettings],\n messages: getFormsMessages,\n },\n adminUi: {\n // \"Submissions (N new)\" beside Publish on a form's canvas (PR08).\n canvasHeaderActions: { [Form.name]: FormSubmissionsHeaderAction },\n pages: {\n FormsListPage,\n FormCanvasPage,\n SubmissionsPage,\n PrivacyRequestsPage,\n },\n sidebar: [\n {\n id: 'forms',\n group: 'Content',\n label: 'Forms',\n href: '/admin/forms',\n iconName: 'clipboard-list',\n // Derived from the list page's gate in any case: whoever may see\n // this site's forms gets the link.\n requires: FORM_LIST_GATE,\n },\n ],\n defaultRoutes: [\n {\n // Owns the `form` entity's list URL — the generic table never shows.\n path: 'forms',\n factory: 'FormsListPage',\n page: {\n // `form:view`, not the build job: the list is also the way in for\n // a person who only reads submissions (D005). What they can do\n // from it is decided inside the page.\n requires: FORM_LIST_GATE,\n defaultRoles: ['admin', 'editor'],\n description: 'List this site’s forms and create new ones.',\n descriptionKey: 'Forms.permissions.list',\n // An agent's browser session may open it (agent-access D020) —\n // the same reach its key already has to `form` through the API,\n // still behind its role's `form:view`.\n tokens: 'accept',\n },\n },\n {\n // Privacy requests (W5) — reached from the Forms list's header;\n // never a menu entry. A literal path: the page router matches\n // literals before `[param]` paths, so it is never read as a form id.\n path: 'forms/privacy',\n factory: 'PrivacyRequestsPage',\n page: {\n // The permission that reads submissions (forms D011): what this\n // screen finds and exports is the same answers. Erasing asks for\n // `form_submissions:manage` on its route.\n requires: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Find one person’s submissions by email address, to export or erase them.',\n descriptionKey: 'Forms.permissions.privacy',\n // Not a page about who holds authority: an agent whose role holds\n // the permission may open it to find and export, as it may a\n // form's Submissions. ERASING is closed to every agent door on\n // its route, and the page does not draw the button for one.\n tokens: 'accept',\n },\n },\n {\n // Owns the `form` entity's edit URL — the canvas, gated by the job.\n path: 'forms/[id]',\n factory: 'FormCanvasPage',\n page: {\n requires: FORMS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Edit a form on its canvas.',\n descriptionKey: 'Forms.permissions.canvas',\n tokens: 'accept',\n },\n },\n {\n // Submissions (W4) — reached from the Forms list, the form's canvas\n // and a Form block's panel; never a menu entry of its own.\n path: 'forms/[id]/submissions',\n factory: 'SubmissionsPage',\n page: {\n requires: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Read and export what visitors sent through a form.',\n descriptionKey: 'Forms.permissions.submissions',\n // An agent whose role holds the permission may open it, as it may\n // the support inbox (agent-access D021).\n tokens: 'accept',\n },\n },\n // The SINGULAR slugs resolve to the same entity (`/admin/form/<id>`),\n // so they are owned too — otherwise the generic editor would answer\n // them behind `form:*` alone, and a form would have two addresses.\n // No `page` spec: the gate wraps the FACTORY once (above), so every\n // path that renders it is gated the same way.\n { path: 'form', factory: 'FormsListPage' },\n { path: 'form/[id]', factory: 'FormCanvasPage' },\n ],\n },\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"plugin.mjs","names":["MAX_BODY_BYTES","UUID_RE","json","isRecord","readBody","loadForm","MAX_BODY_BYTES","json","isRecord","message","exportCopy","csvTime","exportRoute"],"sources":["../src/email-routes.ts","../src/privacy-routes.ts","../src/submission-routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * A form's email settings and delivery Retry — the admin API behind the form\n * panel's Emails tab and the Submissions screen (forms D006). Prefix\n * `form-emails`:\n *\n * - `PATCH form-emails` `{ form, recipients, autoReplyEnabled, autoReplyField }`\n * — who is told of each answer, and whether the visitor is thanked.\n * - `POST form-emails/retry` `{ form, submission, kind }` — queue a failed (or\n * skipped) email again.\n *\n * **Authorization.** `PATCH` is `forms:manage` (building the form), `retry` is\n * `form_submissions:manage` (tending its submissions). The tables are reached\n * through `@murumets-ee/forms`' functions with the app's own database handle,\n * after the route has authorized and the form has been shown to belong to THIS\n * site — another site's id reads as \"not found\" (C2).\n *\n * **Validation.** Strict bodies (any other key is malformed); every recipient is\n * a plain `local@domain.tld` ({@link parseRecipients}), at most ten; the\n * auto-reply field must be an email field of the form AS IT IS NOW.\n *\n * **Why recipients are not in the form.** A form's published tree is delivered to\n * every visitor, so an address in a block prop would be published (F017). They\n * live in `forms_email_settings`, which no public route reads.\n *\n * **Agents.** An agent whose role holds the permission may call them. Changing\n * who receives every answer is the one call here that could redirect visitors'\n * data, so it is `confirm: true`: an agent under a human-approves policy has it\n * parked for a person. A Retry only re-sends to recipients already set.\n *\n * **Personal data (C5).** An audit row names the form and counts. Never an\n * address.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n requireRequestSite,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n compileForm,\n enqueueFormEmail,\n FORM_SUBMISSION_ENTITY,\n FORM_SUBMISSIONS_MANAGE,\n FORMS_MANAGE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n FormSubmission,\n isEmailKind,\n parseRecipients,\n readCappedBody,\n readDeliveries,\n writeEmailSettings,\n} from '@murumets-ee/forms'\n\nexport const EMAIL_ROUTES_PREFIX = 'form-emails'\n\nconst MAX_BODY_BYTES = 8192\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nasync function readBody(req: Request): Promise<{ value: unknown } | { refusal: Response }> {\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n try {\n return { value: JSON.parse(body.text) as unknown }\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n}\n\n/** Exactly these keys, or `null`. */\nfunction strict(value: unknown, keys: readonly string[]): Record<string, unknown> | null {\n if (!isRecord(value)) return null\n const allowed = new Set(keys)\n return Object.keys(value).every((key) => allowed.has(key)) ? value : null\n}\n\n/** This site's form `id` (system read, confined by the site), or `null`. */\nasync function loadForm(app: ToolkitApp, id: string): Promise<{ body: unknown } | null> {\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n return row === null || row === undefined ? null : { body: row.body }\n}\n\nexport const handleSaveEmails: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = strict(body.value, ['form', 'recipients', 'autoReplyEnabled', 'autoReplyField'])\n const { form, recipients, autoReplyEnabled, autoReplyField } = parsed ?? {}\n if (\n parsed === null ||\n typeof form !== 'string' ||\n !UUID_RE.test(form) ||\n typeof autoReplyEnabled !== 'boolean' ||\n !(autoReplyField === null || typeof autoReplyField === 'string')\n ) {\n return json(\n {\n error:\n 'Expected { form: uuid, recipients: string[], autoReplyEnabled: boolean, autoReplyField: string|null }',\n },\n 400,\n )\n }\n const verdict = parseRecipients(recipients)\n if (!verdict.ok) {\n return json(\n { error: 'invalid_recipients', invalid: verdict.invalid, tooMany: verdict.tooMany },\n 400,\n )\n }\n const loaded = await loadForm(ctx.app, form)\n if (loaded === null) return json({ error: 'not_found' }, 404)\n\n // The auto-reply goes to ONE email field of the form as it is now.\n const fields = compileForm(loaded.body)?.fields ?? []\n const emailKeys = fields.filter((field) => field.kind === 'email').map((field) => field.key)\n if (autoReplyField !== null && !emailKeys.includes(autoReplyField)) {\n return json({ error: 'invalid_auto_reply_field' }, 400)\n }\n if (autoReplyEnabled && autoReplyField === null) {\n return json({ error: 'auto_reply_needs_field' }, 400)\n }\n\n await writeEmailSettings(ctx.app, form, {\n recipients: verdict.recipients,\n autoReplyEnabled,\n autoReplyField,\n })\n ctx.audit?.({\n action: 'forms.emails.update',\n entityType: 'form',\n entityId: form,\n metadata: { recipients: verdict.recipients.length, autoReply: autoReplyEnabled },\n })\n return json({ recipients: verdict.recipients, autoReplyEnabled, autoReplyField })\n}\n\n/** A queued email this old has lost its job (worker down, process died). */\nconst STUCK_AFTER_MS = 5 * 60 * 1000\n/** How long a timed-out auto-reply holds its slot — the guard's window. */\nconst GUARD_WINDOW_MS = 60 * 60 * 1000\n/** Skips a changed setting can cure; the others (hourly limit, bad address, gone) would be refused again. */\nconst RETRYABLE_SKIPS: readonly string[] = ['no_recipients', 'disabled']\n\n/** Where a Retry can change the answer — the screen's `canRetry` and this route agree. */\nfunction isRetryable(\n row: { kind: string; status: string; errorClass: string | null; updatedAt: string },\n now: number,\n): boolean {\n if (row.status === 'failed') {\n // A timed-out auto-reply holds its hourly slot (the mail may still arrive):\n // a Retry inside that hour would only be refused.\n const held =\n row.kind === 'autoReply' &&\n row.errorClass === 'timeout' &&\n now - new Date(row.updatedAt).getTime() < GUARD_WINDOW_MS\n return !held\n }\n if (row.status === 'queued') return now - new Date(row.updatedAt).getTime() > STUCK_AFTER_MS\n return (\n row.status === 'skipped' && row.errorClass !== null && RETRYABLE_SKIPS.includes(row.errorClass)\n )\n}\n\nexport const handleRetry: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = strict(body.value, ['form', 'submission', 'kind'])\n const { form, submission, kind } = parsed ?? {}\n if (\n parsed === null ||\n typeof form !== 'string' ||\n !UUID_RE.test(form) ||\n typeof submission !== 'string' ||\n !UUID_RE.test(submission) ||\n !isEmailKind(kind)\n ) {\n return json(\n { error: 'Expected { form: uuid, submission: uuid, kind: notification|autoReply }' },\n 400,\n )\n }\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n // The submission must be this form's — and this site's, which the system\n // client's site binding already guarantees.\n const row: Record<string, unknown> | null | undefined = await ctx.app\n .getSystemClient(FormSubmission)\n .findById(submission)\n if (row === null || row === undefined || String(row.form) !== form) {\n return json({ error: 'not_found' }, 404)\n }\n // The server holds the same line the screen draws: an email that went is not\n // sent again by a replayed request (every call would mail every recipient the\n // full set of answers), and a fresh queued one is still on its way.\n const current = (await readDeliveries(ctx.app, form, [submission])).get(submission) ?? []\n const delivery = current.find((entry) => entry.kind === kind)\n if (delivery !== undefined && !isRetryable(delivery, Date.now())) {\n return json({ error: 'not_retryable', status: delivery.status }, 409)\n }\n const site = await requireRequestSite(FORMS_SETTINGS_NAMESPACE, ctx.app)\n\n const queued = await enqueueFormEmail(ctx.app, {\n site,\n formId: form,\n submissionId: submission,\n kind,\n })\n ctx.audit?.({\n action: 'forms.emails.retry',\n entityType: FORM_SUBMISSION_ENTITY,\n entityId: submission,\n metadata: { kind, queued },\n })\n // `readEmailSettings` is not consulted: a Retry sends to whoever is set NOW,\n // and the job says \"skipped: no_recipients\" if nobody is.\n return json({ queued })\n}\n\nconst saveRoute = defineAdminRoute({\n prefix: EMAIL_ROUTES_PREFIX,\n path: '',\n method: 'PATCH',\n permission: FORMS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Set a form’s email recipients and auto-reply: { form, recipients, … }.',\n descriptionKey: 'Forms.permissions.routeEmails',\n tokens: 'accept',\n // Redirecting every answer to a new address is the one call here that could\n // move visitors' data; an agent under a human-approves policy waits for a person.\n confirm: true,\n handler: handleSaveEmails,\n})\n\nconst retryRoute = defineAdminRoute({\n prefix: EMAIL_ROUTES_PREFIX,\n path: 'retry',\n method: 'POST',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Queue a failed form email again: { form, submission, kind }.',\n descriptionKey: 'Forms.permissions.routeEmailRetry',\n tokens: 'accept',\n handler: handleRetry,\n})\n\n/** The form-email admin routes (`server.routes`). */\nexport function emailRoutes(): AdminRoute[] {\n return combineAdminRoutes([saveRoute, retryRoute])\n}\n","/**\n * The Privacy requests screen's admin API (forms D007, W5) — prefix\n * `form-privacy`:\n *\n * - `POST form-privacy/search` `{ email }` — which of this site's submissions\n * carry that address: form, time and page of each, and the total.\n * - `POST form-privacy/export` `{ email, format, uiLocale? }` — all of them,\n * with their answers under the labels they had, as CSV or JSON.\n * - `DELETE form-privacy` `{ email }` — erase all of them, for good.\n *\n * **The address travels in the request BODY, never the URL** — which is why\n * the two reads are POSTs. A query string is written to access logs, proxy\n * logs and browser history; an address there would be personal data in a log\n * (C5), about the very person asking to be forgotten.\n *\n * **Authorization.** Each method declares its permission, checked by\n * `defineAdminRoute` before the handler runs: finding and exporting is\n * `form_submissions:view`, erasing is `form_submissions:manage` (forms D011).\n * The rows are then reached through `@murumets-ee/forms`' privacy functions,\n * which use an elevated client — sanctioned because the route HAS authorized,\n * on a different resource, and nothing from the body is forwarded into a\n * write: the one input is an address, parsed to a whole address and used as a\n * bound parameter of a lookup. The site is the request's, so another site's\n * submissions are never found (C2).\n *\n * **Erasing names the address again.** The erase takes `{ email }`, not ids\n * from the browser, so what is erased is what the server finds for that\n * address at that moment — a submission that arrived since the search goes\n * too, and a browser cannot name rows of its own choosing.\n *\n * **Agents.** An agent whose role holds the permission may FIND and EXPORT\n * (`tokens: 'accept'`), as it may read a form's Submissions. ERASING is closed\n * to every token-derived door (no `tokens` choice, which is deny): an erase a\n * person has to approve is PARKED with its request body, and that body is the\n * address — stored in the approvals table, and shown on the Approvals page,\n * about the very person asking to be forgotten. A person erases; an agent can\n * prepare the request by finding and exporting.\n *\n * **What a search cannot see.** A submission stored before the email lookup\n * existed is not findable until the nightly clean-up has filled its lookup\n * in. Search and erase answer `unindexed` — how many of this site's\n * submissions that is — and the screen says so, so \"nothing found\" is never\n * mistaken for \"nothing stored\".\n *\n * **Audit (C5).** A row names the action, a count and the form ids. Never the\n * address, never an answer.\n */\n\nimport {\n type CsvExport,\n csvDelimiterFor,\n csvFilename,\n pagedExportRows,\n runCsvExport,\n} from '@murumets-ee/admin-ui/csv-export'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n countSubmissionsByEmail,\n countUnindexedSubmissions,\n eraseSubmissionsByEmail,\n FORM_SUBMISSIONS_MANAGE,\n FORM_SUBMISSIONS_VIEW,\n findSubmissionsByEmail,\n formNames,\n type PersonSubmission,\n PRIVACY_EXPORT_MAX,\n PRIVACY_LIST_MAX,\n parsePrivacyEmail,\n readCappedBody,\n SUBMISSIONS_READ_MAX,\n type SubmissionAnswer,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\nimport type { PrivacyMatch } from './privacy-match.js'\n\nexport const PRIVACY_ROUTES_PREFIX = 'form-privacy'\n\n/** Says whether an export stopped at its row cap — read by the screen. */\nexport const TRUNCATED_HEADER = 'X-Lumi-Export-Truncated'\n\n/** Exports being READ into memory at once in this process; a third is told to wait. */\nconst MAX_EXPORT_READS = 2\nlet reading = 0\n\n/** For tests: how many privacy exports are being read right now. */\nexport function _readingPrivacyExports(): number {\n return reading\n}\n\nfunction busy(): Response {\n return new Response(JSON.stringify({ error: 'export_busy', code: 'export_busy' }), {\n status: 503,\n headers: {\n 'Content-Type': 'application/json',\n 'Cache-Control': 'no-store',\n 'Retry-After': '10',\n },\n })\n}\n\n/** A body is an address and two short words. */\nconst MAX_BODY_BYTES = 2048\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The body's address, with exactly the keys named beside it — strict, so a\n * body carrying anything else is malformed rather than half-read.\n */\nasync function readRequest(\n req: Request,\n extraKeys: readonly string[],\n): Promise<{ email: string; rest: Record<string, unknown> } | { refusal: Response }> {\n // Capped as the bytes arrive — a header can lie or be absent.\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n let value: unknown\n try {\n value = JSON.parse(body.text)\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n const allowed = new Set(['email', ...extraKeys])\n if (!isRecord(value) || Object.keys(value).some((key) => !allowed.has(key))) {\n return { refusal: json({ error: 'invalid_body' }, 400) }\n }\n const email = parsePrivacyEmail(value.email)\n // The answer never echoes what was sent.\n if (email === null)\n return { refusal: json({ error: 'invalid_email', code: 'invalid_email' }, 400) }\n return { email, rest: value }\n}\n\n// ---------------------------------------------------------------------------\n// POST search — what the screen lists\n// ---------------------------------------------------------------------------\n\nexport const handlePrivacySearch: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, [])\n if ('refusal' in request) return request.refusal\n const { email } = request\n const [total, rows, unindexed] = await Promise.all([\n countSubmissionsByEmail(ctx.app, email),\n findSubmissionsByEmail(ctx.app, email, { limit: PRIVACY_LIST_MAX, offset: 0 }),\n countUnindexedSubmissions(ctx.app),\n ])\n const names = await formNames(\n ctx.app,\n rows.map((row) => row.formId),\n )\n const items: PrivacyMatch[] = rows.map((row) => ({\n id: row.id,\n formId: row.formId,\n formName: names.get(row.formId) ?? null,\n createdAt: row.createdAt,\n pagePath: row.pagePath,\n }))\n ctx.audit?.({\n action: 'forms.privacy.search',\n entityType: 'form_submission',\n metadata: { count: total, forms: [...new Set(rows.map((row) => row.formId))], unindexed },\n })\n return json({ total, items, unindexed, exportMax: PRIVACY_EXPORT_MAX })\n}\n\n// ---------------------------------------------------------------------------\n// POST export — everything the person sent, with labels\n// ---------------------------------------------------------------------------\n\nconst FORMATS = ['csv', 'json'] as const\ntype ExportFormat = (typeof FORMATS)[number]\n\ninterface ExportCopy {\n form: string\n received: string\n page: string\n status: string\n yes: string\n no: string\n deletedForm: string\n statuses: Record<string, string>\n}\n\n/** `Forms.<...path>` in a catalogue, when it is a non-empty string. */\nfunction message(catalogue: Record<string, unknown>, path: readonly string[]): string | undefined {\n let node: unknown = catalogue.Forms\n for (const key of path) {\n if (!isRecord(node)) return undefined\n node = node[key]\n }\n return typeof node === 'string' && node !== '' ? node : undefined\n}\n\nasync function exportCopy(locale: string | undefined): Promise<ExportCopy> {\n // The loader answers English for a locale it does not ship, so an unknown\n // `uiLocale` cannot choose a file to import.\n const catalogue = await getFormsMessages(locale ?? 'en')\n const pick = (path: readonly string[], fallback: string) => message(catalogue, path) ?? fallback\n return {\n form: pick(['privacy', 'csv', 'form'], 'Form'),\n received: pick(['privacy', 'csv', 'received'], 'Received (UTC)'),\n page: pick(['privacy', 'csv', 'page'], 'Page'),\n status: pick(['privacy', 'csv', 'status'], 'Status'),\n yes: pick(['submissions', 'yes'], 'yes'),\n no: pick(['submissions', 'no'], 'no'),\n deletedForm: pick(['privacy', 'deletedForm'], 'deleted'),\n statuses: {\n new: pick(['submissionStatus', 'new'], 'New'),\n read: pick(['submissionStatus', 'read'], 'Read'),\n archived: pick(['submissionStatus', 'archived'], 'Archived'),\n },\n }\n}\n\n/** The label an answer had when it was sent; its key when the snapshot has none. */\nfunction labelOf(row: PersonSubmission, key: string): string {\n const label = row.fields[key]?.label\n return label !== undefined && label !== '' ? label : key\n}\n\n/** `{ label: answers }` of one submission — two fields that shared a label share its cell. */\nfunction answersByLabel(row: PersonSubmission): Map<string, SubmissionAnswer[]> {\n const byLabel = new Map<string, SubmissionAnswer[]>()\n for (const [key, value] of Object.entries(row.data)) {\n const label = labelOf(row, key)\n byLabel.set(label, [...(byLabel.get(label) ?? []), value])\n }\n return byLabel\n}\n\nfunction cell(values: readonly SubmissionAnswer[] | undefined, copy: ExportCopy): string | number {\n if (values === undefined || values.length === 0) return ''\n const words = values.map((value) =>\n typeof value === 'boolean' ? (value ? copy.yes : copy.no) : value,\n )\n const only = words[0]\n return words.length === 1 && only !== undefined ? only : words.join(' | ')\n}\n\n/** `2026-10-06 14:02` — a form a spreadsheet reads as a date, in UTC as the header says. */\nfunction csvTime(iso: string): string {\n return iso.slice(0, 16).replace('T', ' ')\n}\n\n/** The person's submissions, page by page, fixed at `until`, at most the export cap. */\nfunction personRows(\n app: ToolkitApp,\n email: string,\n until: Date,\n onTruncated?: (written: number) => void,\n): AsyncGenerator<PersonSubmission, void, undefined> {\n return pagedExportRows<PersonSubmission>(\n (offset, limit) => findSubmissionsByEmail(app, email, { limit, offset, until }),\n {\n pageSize: SUBMISSIONS_READ_MAX,\n maxRows: PRIVACY_EXPORT_MAX,\n ...(onTruncated !== undefined && { onTruncated }),\n },\n )\n}\n\nexport const handlePrivacyExport: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, ['format', 'uiLocale'])\n if ('refusal' in request) return request.refusal\n const { email, rest } = request\n const format: ExportFormat | undefined = FORMATS.find((name) => name === rest.format)\n if (format === undefined) return json({ error: 'invalid_format' }, 400)\n const uiLocale =\n typeof rest.uiLocale === 'string' && rest.uiLocale.length <= 35 ? rest.uiLocale : ctx.locale\n const app = ctx.app\n // The whole set is read ONCE, into memory, before a byte is written: the CSV\n // header needs every label, and a second pass over a set that an erase or\n // the clean-up is changing could meet a row the first pass never saw. So the\n // read is bounded twice — PRIVACY_EXPORT_MAX rows, and MAX_EXPORT_READS\n // reads at a time in this process. (The rows then live until the download\n // ends: what is bounded is the querying, and each file's size.)\n if (reading >= MAX_EXPORT_READS) return busy()\n reading += 1\n const until = new Date()\n const rows: PersonSubmission[] = []\n let truncated = false\n let names: Map<string, string>\n try {\n for await (const row of personRows(app, email, until, () => {\n truncated = true\n })) {\n rows.push(row)\n }\n names = await formNames(app, [...new Set(rows.map((row) => row.formId))])\n } finally {\n reading -= 1\n }\n if (truncated) {\n // A count — nothing a visitor sent, and not the address.\n app.logger.warn(\n { written: rows.length },\n 'forms: a privacy export reached the row cap — the file holds the newest rows only',\n )\n }\n // Written once the file is actually on its way — a refused export (every CSV\n // slot taken) must not be recorded as one somebody received.\n const audit = (): void => {\n ctx.audit?.({\n action: 'forms.privacy.export',\n entityType: 'form_submission',\n metadata: {\n count: rows.length,\n forms: [...new Set(rows.map((row) => row.formId))],\n format,\n truncated,\n },\n })\n }\n\n if (format === 'json') {\n const body = JSON.stringify(\n {\n exportedAt: until.toISOString(),\n truncated,\n submissions: rows.map((row) => ({\n form: names.get(row.formId) ?? null,\n receivedAt: row.createdAt,\n page: row.pagePath,\n language: row.locale,\n status: row.status,\n answers: Object.entries(row.data).map(([key, value]) => ({\n field: labelOf(row, key),\n key,\n answer: value,\n })),\n consent: Object.values(row.consent),\n })),\n },\n null,\n 2,\n )\n audit()\n return new Response(body, {\n status: 200,\n headers: {\n 'Content-Type': 'application/json; charset=utf-8',\n 'Content-Disposition': `attachment; filename=\"${csvFilename('privacy-request').replace(/\\.csv$/, '.json')}\"`,\n 'Cache-Control': 'private, no-store',\n 'X-Content-Type-Options': 'nosniff',\n [TRUNCATED_HEADER]: String(truncated),\n },\n })\n }\n\n const copy = await exportCopy(uiLocale)\n const labels: string[] = []\n const seen = new Set<string>()\n for (const row of rows) {\n for (const key of Object.keys(row.data)) {\n const label = labelOf(row, key)\n if (seen.has(label)) continue\n seen.add(label)\n labels.push(label)\n }\n }\n const res = await runCsvExport(\n (): CsvExport => ({\n filename: csvFilename('privacy-request'),\n delimiter: csvDelimiterFor(uiLocale),\n header: [copy.form, copy.received, copy.page, copy.status, ...labels],\n rows: rows.map((row) => {\n const answers = answersByLabel(row)\n return [\n names.get(row.formId) ?? copy.deletedForm,\n csvTime(row.createdAt),\n row.pagePath ?? '',\n copy.statuses[row.status] ?? row.status,\n ...labels.map((label) => cell(answers.get(label), copy)),\n ]\n }),\n }),\n )\n if (res.ok) {\n audit()\n res.headers.set(TRUNCATED_HEADER, String(truncated))\n }\n return res\n}\n\n// ---------------------------------------------------------------------------\n// DELETE — erase, for good\n// ---------------------------------------------------------------------------\n\nexport const handlePrivacyErase: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, [])\n if ('refusal' in request) return request.refusal\n // Never throws part-way: what went before a failed batch is reported, so it\n // reaches the audit row below either way.\n const result = await eraseSubmissionsByEmail(ctx.app, request.email)\n const unindexed = await countUnindexedSubmissions(ctx.app).catch(() => null)\n ctx.audit?.({\n action: 'forms.privacy.erase',\n entityType: 'form_submission',\n // How many, from which forms — never whose (D007).\n metadata: {\n count: result.erased,\n forms: [...result.formIds],\n unfinished: result.more,\n failed: result.failed,\n unindexed,\n },\n })\n return json(\n {\n erased: result.erased,\n forms: result.formIds.length,\n more: result.more,\n failed: result.failed,\n unindexed,\n },\n result.failed ? 500 : 200,\n )\n}\n\n// ---------------------------------------------------------------------------\n// Declarations\n// ---------------------------------------------------------------------------\n\nconst searchRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: 'search',\n method: 'POST',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Find this site’s submissions by one whole email address: { email }.',\n descriptionKey: 'Forms.permissions.routePrivacySearch',\n tokens: 'accept',\n handler: handlePrivacySearch,\n})\n\nconst exportRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: 'export',\n method: 'POST',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Download one person’s submissions as CSV or JSON: { email, format }.',\n descriptionKey: 'Forms.permissions.routePrivacyExport',\n tokens: 'accept',\n handler: handlePrivacyExport,\n})\n\nconst eraseRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: '',\n method: 'DELETE',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Erase one person’s submissions for good: { email }. People only, no agents.',\n descriptionKey: 'Forms.permissions.routePrivacyErase',\n // Closed to every token-derived door, and said so (agent-access PR14). A\n // parked erase would store its body — the address — in the approvals table\n // (see the header). A person erases, from their own session.\n tokens: { refuse: 'personal-data' },\n handler: handlePrivacyErase,\n})\n\n/** The Privacy requests screen's admin routes (`server.routes`). */\nexport function privacyRoutes(): AdminRoute[] {\n return combineAdminRoutes([searchRoute, exportRoute, eraseRoute])\n}\n","/**\n * The Submissions screen's admin API (forms D005, W4) — prefix\n * `form-submissions`:\n *\n * - `GET form-submissions/export?form=<id>&…filters` — the filtered set as a CSV.\n * - `GET form-submissions/summary?form=<id>` — `{ total, fresh }`, for the\n * \"Submissions (3 new)\" link on a Form block's panel (W3).\n * - `PATCH form-submissions` `{ form, ids, status }` — mark new / read / archived.\n * - `DELETE form-submissions` `{ form, ids }` — delete for good.\n *\n * (The screen's own rows are read on the server by the page; there is no list\n * route to keep in step with it.)\n *\n * **Authorization.** Each method declares its permission, checked by\n * `defineAdminRoute` before the handler runs: reading and exporting is\n * `form_submissions:view`, changing and deleting is `form_submissions:manage`.\n * The rows are then reached through `@murumets-ee/forms`' submission functions,\n * which use an elevated client — sanctioned here because the route HAS\n * authorized, on a different resource, and because nothing from the request\n * body is forwarded into a write: a status is one of three literals and ids\n * are UUIDs, both validated below. The site is the request's, and every query\n * names the form, so another site's form id reads as \"not found\" (C2).\n *\n * **Agents.** An agent whose role holds the permission may use these routes\n * (`tokens: 'accept'`), as it may the support inbox (agent-access D021);\n * deleting is `confirm: true`, so an agent under a human-approves policy has\n * its delete parked for a person.\n *\n * **Personal data (C5).** An audit row names the form, the action and a count.\n * Never an answer.\n *\n * Lives in forms-ui, not `@murumets-ee/forms`, because the export answers\n * through admin-ui's CSV writer and the forms package does not depend on\n * admin-ui. Reached from the jiti-loaded plugin entry, so nothing here imports\n * a `server-only` module.\n */\n\nimport {\n type CsvExport,\n csvDelimiterFor,\n csvFilename,\n pagedExportRows,\n runCsvExport,\n} from '@murumets-ee/admin-ui/csv-export'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n collectRetiredColumns,\n currentFormFields,\n deleteSubmissions,\n FORM_SUBMISSIONS_MANAGE,\n FORM_SUBMISSIONS_VIEW,\n Form,\n isSubmissionStatus,\n listSubmissionRows,\n MAX_SUBMISSION_BULK,\n parseSubmissionFilters,\n readCappedBody,\n SUBMISSIONS_READ_MAX,\n type SubmissionAnswer,\n type SubmissionRow,\n setSubmissionStatus,\n submissionColumns,\n submissionCounts,\n submissionFilterParams,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\n\nexport const SUBMISSION_ROUTES_PREFIX = 'form-submissions'\n\n/** A body is a form id, a status and at most a hundred ids. */\nconst MAX_BODY_BYTES = 8192\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** `Array.isArray` on `unknown` narrows to `any[]`; this keeps the elements `unknown`. */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * `{ form, ids, ...extra }` with exactly the keys named — strict, so a body\n * carrying anything else is malformed rather than half-read. `null` otherwise.\n */\nfunction parseTarget(\n value: unknown,\n extraKeys: readonly string[],\n): { form: string; ids: string[]; rest: Record<string, unknown> } | null {\n if (!isRecord(value)) return null\n const allowed = new Set(['form', 'ids', ...extraKeys])\n if (Object.keys(value).some((key) => !allowed.has(key))) return null\n const { form, ids } = value\n if (typeof form !== 'string' || !UUID_RE.test(form)) return null\n if (!isUnknownArray(ids) || ids.length === 0 || ids.length > MAX_SUBMISSION_BULK) return null\n const clean = ids.filter((id): id is string => typeof id === 'string' && UUID_RE.test(id))\n if (clean.length !== ids.length) return null\n return { form, ids: clean, rest: value }\n}\n\n/** The request body as JSON, or the refusal to answer with. */\nasync function readBody(req: Request): Promise<{ value: unknown } | { refusal: Response }> {\n // Capped as the bytes arrive — a header can lie or be absent.\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n const raw = body.text\n try {\n const value: unknown = JSON.parse(raw)\n return { value }\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n}\n\n/**\n * This site's form `id`, or `null`. Read with an elevated client for the same\n * reason the submissions are: the route's own permission is what authorized\n * seeing this form's name and fields, and the site still confines the read —\n * another site's id is not found.\n */\nasync function loadForm(\n app: ToolkitApp,\n id: string,\n): Promise<{ name: string; body: unknown } | null> {\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n if (row === null || row === undefined) return null\n return { name: typeof row.name === 'string' ? row.name : 'form', body: row.body }\n}\n\n// ---------------------------------------------------------------------------\n// GET summary — two numbers, for the link on a Form block's panel\n// ---------------------------------------------------------------------------\n\nexport const handleSummary: AdminRouteHandler = async (req, ctx) => {\n const formId = new URL(req.url).searchParams.get('form') ?? ''\n if (!UUID_RE.test(formId)) return json({ error: 'not_found' }, 404)\n if ((await loadForm(ctx.app, formId)) === null) return json({ error: 'not_found' }, 404)\n const counts = (await submissionCounts(ctx.app, [formId])).get(formId)\n return json({ total: counts?.total ?? 0, fresh: counts?.fresh ?? 0 })\n}\n\n// ---------------------------------------------------------------------------\n// PATCH — mark new / read / archived\n// ---------------------------------------------------------------------------\n\nexport const handleSetStatus: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = parseTarget(body.value, ['status'])\n const status = parsed?.rest.status\n if (parsed === null || !isSubmissionStatus(status)) {\n return json({ error: 'Expected { form: uuid, ids: uuid[], status: new|read|archived }' }, 400)\n }\n const { form } = parsed\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n\n const changed = await setSubmissionStatus(ctx.app, form, parsed.ids, status)\n ctx.audit?.({\n action: 'forms.submissions.status',\n entityType: 'form',\n entityId: form,\n metadata: { status, count: changed },\n })\n return json({ changed })\n}\n\n// ---------------------------------------------------------------------------\n// DELETE — for good\n// ---------------------------------------------------------------------------\n\nexport const handleDelete: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = parseTarget(body.value, [])\n if (parsed === null) return json({ error: 'Expected { form: uuid, ids: uuid[] }' }, 400)\n const { form } = parsed\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n\n const deleted = await deleteSubmissions(ctx.app, form, parsed.ids)\n ctx.audit?.({\n action: 'forms.submissions.delete',\n entityType: 'form',\n entityId: form,\n metadata: { count: deleted },\n })\n return json({ deleted })\n}\n\n// ---------------------------------------------------------------------------\n// GET export — the filtered set as a CSV\n// ---------------------------------------------------------------------------\n\n/** The words a CSV needs, in the reader's interface language (English when the catalogue lacks one). */\ninterface ExportCopy {\n received: string\n page: string\n status: string\n yes: string\n no: string\n removed: string\n statuses: Record<string, string>\n}\n\nconst ENGLISH_COPY: ExportCopy = {\n received: 'Received (UTC)',\n page: 'Page',\n status: 'Status',\n yes: 'yes',\n no: 'no',\n removed: 'removed field',\n statuses: { new: 'New', read: 'Read', archived: 'Archived' },\n}\n\n/** `Forms.<...path>` in a catalogue, when it is a non-empty string. */\nfunction message(catalogue: Record<string, unknown>, path: readonly string[]): string | undefined {\n let node: unknown = catalogue.Forms\n for (const key of path) {\n if (!isRecord(node)) return undefined\n node = node[key]\n }\n return typeof node === 'string' && node !== '' ? node : undefined\n}\n\nasync function exportCopy(locale: string | undefined): Promise<ExportCopy> {\n // The loader answers English for a locale it does not ship, so a hostile or\n // unknown `uiLocale` cannot choose a file to import.\n const catalogue = await getFormsMessages(locale ?? 'en')\n const pick = (path: readonly string[], fallback: string) => message(catalogue, path) ?? fallback\n return {\n received: pick(['submissions', 'csv', 'received'], ENGLISH_COPY.received),\n page: pick(['submissions', 'csv', 'page'], ENGLISH_COPY.page),\n status: pick(['submissions', 'csv', 'status'], ENGLISH_COPY.status),\n yes: pick(['submissions', 'yes'], ENGLISH_COPY.yes),\n no: pick(['submissions', 'no'], ENGLISH_COPY.no),\n removed: pick(['submissions', 'removedField'], ENGLISH_COPY.removed),\n statuses: {\n new: pick(['submissionStatus', 'new'], 'New'),\n read: pick(['submissionStatus', 'read'], 'Read'),\n archived: pick(['submissionStatus', 'archived'], 'Archived'),\n },\n }\n}\n\n/** `2026-10-06 14:02` — a form a spreadsheet reads as a date, in UTC as the header says. */\nfunction csvTime(iso: string): string {\n return iso.slice(0, 16).replace('T', ' ')\n}\n\nfunction csvAnswer(value: SubmissionAnswer | undefined, copy: ExportCopy): string | number {\n if (value === undefined) return ''\n if (typeof value === 'boolean') return value ? copy.yes : copy.no\n return value\n}\n\nexport const handleExport: AdminRouteHandler = async (req, ctx) => {\n const url = new URL(req.url)\n const formId = url.searchParams.get('form') ?? ''\n if (!UUID_RE.test(formId)) return json({ error: 'not_found' }, 404)\n const form = await loadForm(ctx.app, formId)\n if (form === null) return json({ error: 'not_found' }, 404)\n\n // `until`: the set is read twice (below), so it is fixed at the moment the\n // export starts — a submission arriving mid-export is simply in the next one.\n // (A row stamped a moment before this instant and committed a moment after\n // the first pass can still shift one row across a page boundary: the window\n // is the length of an insert, and the cost is one repeated row.)\n const filters = {\n ...parseSubmissionFilters((name) => url.searchParams.get(name)),\n until: new Date(),\n }\n const uiLocale = url.searchParams.get('uiLocale') ?? ctx.locale\n const current = currentFormFields(form.body)\n const app = ctx.app\n const pages = (onTruncated?: (written: number) => void) =>\n pagedExportRows<SubmissionRow>(\n (offset, limit) => listSubmissionRows(app, formId, filters, { limit, offset }),\n // Exactly the reader's own page size: asking for more would be answered\n // with fewer, which reads as \"the last page\".\n { pageSize: SUBMISSIONS_READ_MAX, ...(onTruncated !== undefined && { onTruncated }) },\n )\n\n return runCsvExport(async (): Promise<CsvExport> => {\n ctx.audit?.({\n action: 'forms.submissions.export',\n entityType: 'form',\n entityId: formId,\n metadata: { filtered: submissionFilterParams(filters).size > 0 },\n })\n const copy = await exportCopy(uiLocale)\n // The header has to be written before the first row, and a retired field\n // may first appear on the last page — so the set is read once for its\n // keys, then again for its rows. Both reads are paged and capped.\n const retired = new Map<string, string>()\n for await (const row of pages()) collectRetiredColumns(current, [row], retired)\n const columns = submissionColumns(current, retired)\n\n return {\n filename: csvFilename(form.name),\n delimiter: csvDelimiterFor(uiLocale),\n header: [\n copy.received,\n copy.page,\n copy.status,\n ...columns.map((column) =>\n column.retired ? `${column.label} (${copy.removed})` : column.label,\n ),\n ],\n rows: (async function* () {\n const rows = pages((written) => {\n // The form and a count — nothing a visitor sent.\n app.logger.warn(\n { formId, written },\n 'forms: a submissions export reached the row cap — the file holds the newest rows only',\n )\n })\n for await (const row of rows) {\n yield [\n csvTime(row.createdAt),\n row.pagePath ?? '',\n copy.statuses[row.status] ?? row.status,\n ...columns.map((column) => csvAnswer(row.data[column.key], copy)),\n ]\n }\n })(),\n }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Declarations\n// ---------------------------------------------------------------------------\n\nconst exportRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: 'export',\n method: 'GET',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Download a form’s submissions as CSV: ?form=<id> plus the screen’s filters.',\n descriptionKey: 'Forms.permissions.routeExport',\n tokens: 'accept',\n handler: handleExport,\n})\n\nconst summaryRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: 'summary',\n method: 'GET',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'How many submissions a form holds and how many are new: ?form=<id>.',\n descriptionKey: 'Forms.permissions.routeSummary',\n tokens: 'accept',\n handler: handleSummary,\n})\n\nconst statusRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: '',\n method: 'PATCH',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Mark submissions of a form new, read or archived: { form, ids, status }.',\n descriptionKey: 'Forms.permissions.routeStatus',\n tokens: 'accept',\n handler: handleSetStatus,\n})\n\nconst deleteRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: '',\n method: 'DELETE',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Delete submissions of a form for good: { form, ids }.',\n descriptionKey: 'Forms.permissions.routeDelete',\n tokens: 'accept',\n // Destroys visitors' answers, up to a hundred at a time.\n confirm: true,\n handler: handleDelete,\n})\n\n/** The Submissions screen's admin routes (`server.routes`). */\nexport function submissionRoutes(): AdminRoute[] {\n return combineAdminRoutes([exportRoute, summaryRoute, statusRoute, deleteRoute])\n}\n","/**\n * `formsPlugin()` — everything `@murumets-ee/forms` contributes, declared as\n * data on one Plugin object (CLAUDE.md \"all plugin contributions are\n * declarative\"). Drop it into `plugins:` and the framework wires:\n *\n * - the `form` entity (site-bound canvas document), the `form_submission`\n * entity and the per-form spam count table;\n * - the per-site `forms` settings namespace (CAPTCHA site keys);\n * - the block vocabulary to the SERVER — `blocks.blocks` (so the tree walk\n * that judges every write knows `ancestor` / `uniqueKey`), the `form`\n * environment base (so the canvas resolves its palette), and the Form block\n * in the site's `page` environment (D002 — a saved form on any page);\n * - the admin routes: \"Detach a copy\", \"delete a form with its submissions\",\n * the Submissions screen's (status, delete, CSV export, summary) and\n * Privacy requests' (find by email, export, erase);\n * - the permissions — `forms:manage`, the `form` entity grants and the\n * `form_submissions` row (view / manage) — to the Permission Matrix, seeded\n * for `editor`;\n * - the catalogue (`shared.messages`);\n * - the Forms list, the form canvas, a form's Submissions and Privacy requests\n * as pages, and the sidebar link;\n * - the daily clean-up job that deletes submissions older than their form's\n * period (D007), and the table that says when it last ran.\n *\n * The browser half — renders and the panel's field editors — is\n * `<FormBlocksRegistration />` from `@murumets-ee/forms-ui`, mounted by the\n * admin layout (the standing client-registry exception in CLAUDE.md).\n *\n * jiti-safe: this module is reached from `lumi.config.ts`, so the pages load\n * lazily and nothing here imports JSX or the blocks root.\n */\n\n// The `blocks.*` contribution slots are declared by module augmentation in\n// `@murumets-ee/blocks`; the type-only import is what brings them into scope.\nimport type {} from '@murumets-ee/blocks'\nimport {\n definePlugin,\n type PageFactory,\n type PluginNotificationTypeDefinition,\n} from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_VIEW,\n FORMS_MANAGE,\n Form,\n FormSubmission,\n formEmailTypes,\n formsFeatures,\n formsRoutes,\n formsSettings,\n formsTables,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\nimport {\n FormEmbedBlock,\n formEnvironment,\n formPageEnvironmentExtension,\n formSchemaBlocks,\n} from '@murumets-ee/forms/schema'\nimport { FormSubmissionsHeaderAction } from '@murumets-ee/forms-ui/canvas-header-action'\nimport { emailRoutes } from './email-routes.js'\nimport { privacyRoutes } from './privacy-routes.js'\nimport { submissionRoutes } from './submission-routes.js'\n\n/** Seeing the Forms list is seeing this site's forms — the `form` entity's own `view`. */\nconst FORM_LIST_GATE = `${Form.name}:view` as const\n\nconst FormsListPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.FormsListPage(props)\n}\nconst FormCanvasPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.FormCanvasPage(props)\n}\nconst SubmissionsPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.SubmissionsPage(props)\n}\n\nconst PrivacyRequestsPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.PrivacyRequestsPage(props)\n}\n\n/** The daily clean-up of expired submissions (forms D007) — see `runFormsRetention`. */\nexport const FORMS_RETENTION_JOB = 'forms:retention'\n\n/** Nightly, off the hour so it does not start with every other 03:00 job. */\nexport const FORMS_RETENTION_SCHEDULE = '23 3 * * *'\n\nexport function formsPlugin() {\n return definePlugin({\n name: '@murumets-ee/forms',\n // `publishable()` keeps its drafts in tables the content plugin owns; the\n // per-site Forms settings (CAPTCHA site keys) live in the settings plugin.\n // The emails are `message_template` rows sent by a queue job: the template\n // editor and the type registry are notifications', the job is the queue's.\n requires: [\n '@murumets-ee/content',\n '@murumets-ee/settings',\n '@murumets-ee/notifications',\n '@murumets-ee/queue',\n ],\n server: {\n // `form_submission` is written only by the public submission path\n // (`@murumets-ee/forms/server`, mounted on the content API) and read by\n // the submissions screen (PR04).\n entities: [Form, FormSubmission],\n // The per-form \"dropped as spam\" counts (D004) — a number per form.\n // …and, per site, when the daily clean-up last ran and what it removed (D007).\n // One list (`formsTables`), so the erasure test walks exactly these.\n tables: { ...formsTables },\n // `POST /api/admin/form-copy` — the Form block's \"Detach a copy\" —,\n // `DELETE /api/admin/form-remove`, the Submissions screen's routes\n // (`/api/admin/form-submissions`) and Privacy requests'\n // (`/api/admin/form-privacy`).\n routes: [...formsRoutes(), ...submissionRoutes(), ...privacyRoutes(), ...emailRoutes()],\n // The daily clean-up. There is no `Plugin.server.jobs` slot to declare\n // into — a job is registered from `init`, as every plugin's is (auth's\n // approvals sweep, the queue's own trash purge); the queue materialises\n // its schedule when a worker starts. Without the queue plugin there is\n // no worker and nothing to register with: the Forms list then says the\n // clean-up has never run, rather than implying a promise is being kept.\n init: async (app) => {\n if (!app.plugins.has('@murumets-ee/queue')) return\n const { defineJob, registerJob } = await import('@murumets-ee/queue/client')\n registerJob(\n // No `description`: the queue catalogue has no translation-key slot,\n // so one would be English on every locale. What it does is above.\n defineJob({ name: FORMS_RETENTION_JOB, schedule: FORMS_RETENTION_SCHEDULE }),\n async () => {\n const { runAsCli, runInEverySite } = await import('@murumets-ee/core')\n const { runFormsRetention } = await import('@murumets-ee/forms')\n // As the synthetic `cli` admin — a job has no signed-in user — and\n // once per site: each run reads and deletes within ONE site. A\n // failure is rethrown so the queue retries and the dead-letter\n // alert fires; a promise to delete must not fail silently.\n const runs = await runAsCli(() =>\n runInEverySite(() => runFormsRetention(app), { app, label: FORMS_RETENTION_JOB }),\n )\n const removed = runs.reduce((sum, run) => sum + run.removed, 0)\n // Counts only — never what was removed (C5).\n app.logger.info(\n {\n sites: runs.length,\n removed,\n unfinished: runs.filter((run) => run.unfinished).length,\n backfilled: runs.reduce((sum, run) => sum + run.backfilled, 0),\n },\n 'forms: daily clean-up of expired submissions finished',\n )\n },\n )\n\n // forms PR05 — the email job: `forms:send_email` (see `@murumets-ee/forms/email-jobs`).\n const { runAsCli: runAsCliEmail } = await import('@murumets-ee/core')\n const { runFormEmailJob, sendFormEmailJob } = await import('@murumets-ee/forms/email-jobs')\n registerJob(sendFormEmailJob, async (job) => {\n await runAsCliEmail(async () => {\n let defaultLocale = 'en'\n try {\n const { getNotificationsConfig } = await import('@murumets-ee/notifications/plugin')\n defaultLocale = getNotificationsConfig().defaultLocale\n } catch {\n app.logger.warn(\n { outcome: 'no_default_locale' },\n 'forms: notifications default locale unavailable — using en',\n )\n }\n await runFormEmailJob(job.payload, { app, logger: app.logger, defaultLocale })\n })\n })\n },\n },\n contributes: {\n // The form document's blocks, and the page's Form block (D002).\n 'blocks.blocks': [...formSchemaBlocks, FormEmbedBlock],\n 'blocks.environmentBases': [formEnvironment],\n // The site's pages may hold a Form block, and the page environment\n // holds the form's blocks too, because a page DRAWS an embedded form\n // with the site theme (see `formPageEnvironmentExtension`).\n 'blocks.environments': formPageEnvironmentExtension,\n },\n shared: {\n features: formsFeatures,\n // The notification and the auto-reply — `notifications()` walks this and\n // fills the type registry (and so the template editor).\n notifications: [...formEmailTypes] as unknown as PluginNotificationTypeDefinition[],\n settings: [formsSettings],\n messages: getFormsMessages,\n },\n adminUi: {\n // \"Submissions (N new)\" beside Publish on a form's canvas (PR08).\n canvasHeaderActions: { [Form.name]: FormSubmissionsHeaderAction },\n pages: {\n FormsListPage,\n FormCanvasPage,\n SubmissionsPage,\n PrivacyRequestsPage,\n },\n sidebar: [\n {\n id: 'forms',\n group: 'Content',\n label: 'Forms',\n href: '/admin/forms',\n iconName: 'clipboard-list',\n // Derived from the list page's gate in any case: whoever may see\n // this site's forms gets the link.\n requires: FORM_LIST_GATE,\n },\n ],\n defaultRoutes: [\n {\n // Owns the `form` entity's list URL — the generic table never shows.\n path: 'forms',\n factory: 'FormsListPage',\n page: {\n // `form:view`, not the build job: the list is also the way in for\n // a person who only reads submissions (D005). What they can do\n // from it is decided inside the page.\n requires: FORM_LIST_GATE,\n defaultRoles: ['admin', 'editor'],\n description: 'List this site’s forms and create new ones.',\n descriptionKey: 'Forms.permissions.list',\n // An agent's browser session may open it (agent-access D020) —\n // the same reach its key already has to `form` through the API,\n // still behind its role's `form:view`.\n tokens: 'accept',\n },\n },\n {\n // Privacy requests (W5) — reached from the Forms list's header;\n // never a menu entry. A literal path: the page router matches\n // literals before `[param]` paths, so it is never read as a form id.\n path: 'forms/privacy',\n factory: 'PrivacyRequestsPage',\n page: {\n // The permission that reads submissions (forms D011): what this\n // screen finds and exports is the same answers. Erasing asks for\n // `form_submissions:manage` on its route.\n requires: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Find one person’s submissions by email address, to export or erase them.',\n descriptionKey: 'Forms.permissions.privacy',\n // Not a page about who holds authority: an agent whose role holds\n // the permission may open it to find and export, as it may a\n // form's Submissions. ERASING is closed to every agent door on\n // its route, and the page does not draw the button for one.\n tokens: 'accept',\n },\n },\n {\n // Owns the `form` entity's edit URL — the canvas, gated by the job.\n path: 'forms/[id]',\n factory: 'FormCanvasPage',\n page: {\n requires: FORMS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Edit a form on its canvas.',\n descriptionKey: 'Forms.permissions.canvas',\n tokens: 'accept',\n },\n },\n {\n // Submissions (W4) — reached from the Forms list, the form's canvas\n // and a Form block's panel; never a menu entry of its own.\n path: 'forms/[id]/submissions',\n factory: 'SubmissionsPage',\n page: {\n requires: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Read and export what visitors sent through a form.',\n descriptionKey: 'Forms.permissions.submissions',\n // An agent whose role holds the permission may open it, as it may\n // the support inbox (agent-access D021).\n tokens: 'accept',\n },\n },\n // The SINGULAR slugs resolve to the same entity (`/admin/form/<id>`),\n // so they are owned too — otherwise the generic editor would answer\n // them behind `form:*` alone, and a form would have two addresses.\n // No `page` spec: the gate wraps the FACTORY once (above), so every\n // path that renders it is gated the same way.\n { path: 'form', factory: 'FormsListPage' },\n { path: 'form/[id]', factory: 'FormCanvasPage' },\n ],\n },\n })\n}\n"],"mappings":"o8CAyDA,MAAa,EAAsB,cAG7BC,EAAU,kEAEhB,SAASC,EAAK,EAAe,EAAS,IAAe,CACnD,OAAO,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACxC,SACA,QAAS,CAAE,eAAgB,mBAAoB,gBAAiB,UAAW,CAC7E,CAAC,CACH,CAEA,SAASC,GAAS,EAAkD,CAClE,OAAO,OAAO,GAAU,YAAY,GAAkB,CAAC,MAAM,QAAQ,CAAK,CAC5E,CAEA,eAAeC,EAAS,EAAmE,CACzF,IAAM,EAAO,MAAM,EAAe,EAAKJ,IAAc,EACrD,GAAI,aAAc,EAAM,MAAO,CAAE,QAASE,EAAK,CAAE,MAAO,gBAAiB,EAAG,GAAG,CAAE,EACjF,GAAI,CACF,MAAO,CAAE,MAAO,KAAK,MAAM,EAAK,IAAI,CAAa,CACnD,MAAQ,CACN,MAAO,CAAE,QAASA,EAAK,CAAE,MAAO,cAAe,EAAG,GAAG,CAAE,CACzD,CACF,CAGA,SAAS,EAAO,EAAgB,EAAyD,CACvF,GAAI,CAACC,GAAS,CAAK,EAAG,OAAO,KAC7B,IAAM,EAAU,IAAI,IAAI,CAAI,EAC5B,OAAO,OAAO,KAAK,CAAK,CAAC,CAAC,MAAO,GAAQ,EAAQ,IAAI,CAAG,CAAC,EAAI,EAAQ,IACvE,CAGA,eAAeE,EAAS,EAAiB,EAA+C,CACtF,IAAM,EAAkD,MAAM,EAC3D,gBAAgB,CAAI,CAAC,CACrB,SAAS,CAAE,EACd,OAAO,GAAQ,KAA4B,KAAO,CAAE,KAAM,EAAI,IAAK,CACrE,CAEA,MAAa,GAAsC,MAAO,EAAK,IAAQ,CACrE,IAAM,EAAO,MAAMD,EAAS,CAAG,EAC/B,GAAI,YAAa,EAAM,OAAO,EAAK,QACnC,IAAM,EAAS,EAAO,EAAK,MAAO,CAAC,OAAQ,aAAc,mBAAoB,gBAAgB,CAAC,EACxF,CAAE,OAAM,aAAY,mBAAkB,kBAAmB,GAAU,CAAC,EAC1E,GACE,IAAW,MACX,OAAO,GAAS,UAChB,CAACH,EAAQ,KAAK,CAAI,GAClB,OAAO,GAAqB,WAC5B,EAAE,IAAmB,MAAQ,OAAO,GAAmB,UAEvD,OAAOC,EACL,CACE,MACE,uGACJ,EACA,GACF,EAEF,IAAM,EAAU,GAAgB,CAAU,EAC1C,GAAI,CAAC,EAAQ,GACX,OAAOA,EACL,CAAE,MAAO,qBAAsB,QAAS,EAAQ,QAAS,QAAS,EAAQ,OAAQ,EAClF,GACF,EAEF,IAAM,EAAS,MAAMG,EAAS,EAAI,IAAK,CAAI,EAC3C,GAAI,IAAW,KAAM,OAAOH,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAI5D,IAAM,GADS,EAAY,EAAO,IAAI,CAAC,EAAE,QAAU,CAAC,EAAA,CAC3B,OAAQ,GAAU,EAAM,OAAS,OAAO,CAAC,CAAC,IAAK,GAAU,EAAM,GAAG,EAmB3F,OAlBI,IAAmB,MAAQ,CAAC,EAAU,SAAS,CAAc,EACxDA,EAAK,CAAE,MAAO,0BAA2B,EAAG,GAAG,EAEpD,GAAoB,IAAmB,KAClCA,EAAK,CAAE,MAAO,wBAAyB,EAAG,GAAG,GAGtD,MAAM,GAAmB,EAAI,IAAK,EAAM,CACtC,WAAY,EAAQ,WACpB,mBACA,gBACF,CAAC,EACD,EAAI,QAAQ,CACV,OAAQ,sBACR,WAAY,OACZ,SAAU,EACV,SAAU,CAAE,WAAY,EAAQ,WAAW,OAAQ,UAAW,CAAiB,CACjF,CAAC,EACMA,EAAK,CAAE,WAAY,EAAQ,WAAY,mBAAkB,gBAAe,CAAC,EAClF,EAOM,GAAqC,CAAC,gBAAiB,UAAU,EAGvE,SAAS,GACP,EACA,EACS,CAWT,OAVI,EAAI,SAAW,SAOV,EAHL,EAAI,OAAS,aACb,EAAI,aAAe,WACnB,EAAM,IAAI,KAAK,EAAI,SAAS,CAAC,CAAC,QAAQ,EAAI,MAG1C,EAAI,SAAW,SAAiB,EAAM,IAAI,KAAK,EAAI,SAAS,CAAC,CAAC,QAAQ,EAAI,IAE5E,EAAI,SAAW,WAAa,EAAI,aAAe,MAAQ,GAAgB,SAAS,EAAI,UAAU,CAElG,CAEA,MAAa,GAAiC,MAAO,EAAK,IAAQ,CAChE,IAAM,EAAO,MAAME,EAAS,CAAG,EAC/B,GAAI,YAAa,EAAM,OAAO,EAAK,QACnC,IAAM,EAAS,EAAO,EAAK,MAAO,CAAC,OAAQ,aAAc,MAAM,CAAC,EAC1D,CAAE,OAAM,aAAY,QAAS,GAAU,CAAC,EAC9C,GACE,IAAW,MACX,OAAO,GAAS,UAChB,CAACH,EAAQ,KAAK,CAAI,GAClB,OAAO,GAAe,UACtB,CAACA,EAAQ,KAAK,CAAU,GACxB,CAAC,GAAY,CAAI,EAEjB,OAAOC,EACL,CAAE,MAAO,yEAA0E,EACnF,GACF,EAEF,GAAK,MAAMG,EAAS,EAAI,IAAK,CAAI,IAAO,KAAM,OAAOH,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAGrF,IAAM,EAAkD,MAAM,EAAI,IAC/D,gBAAgB,CAAc,CAAC,CAC/B,SAAS,CAAU,EACtB,GAAI,GAAQ,MAA6B,OAAO,EAAI,IAAI,IAAM,EAC5D,OAAOA,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAMzC,IAAM,IADW,MAAM,GAAe,EAAI,IAAK,EAAM,CAAC,CAAU,CAAC,EAAA,CAAG,IAAI,CAAU,GAAK,CAAC,EAAA,CAC/D,KAAM,GAAU,EAAM,OAAS,CAAI,EAC5D,GAAI,IAAa,IAAA,IAAa,CAAC,GAAY,EAAU,KAAK,IAAI,CAAC,EAC7D,OAAOA,EAAK,CAAE,MAAO,gBAAiB,OAAQ,EAAS,MAAO,EAAG,GAAG,EAEtE,IAAM,EAAO,MAAM,EAAmB,EAA0B,EAAI,GAAG,EAEjE,EAAS,MAAM,GAAiB,EAAI,IAAK,CAC7C,OACA,OAAQ,EACR,aAAc,EACd,MACF,CAAC,EASD,OARA,EAAI,QAAQ,CACV,OAAQ,qBACR,WAAY,EACZ,SAAU,EACV,SAAU,CAAE,OAAM,QAAO,CAC3B,CAAC,EAGMA,EAAK,CAAE,QAAO,CAAC,CACxB,EAEM,GAAY,EAAiB,CACjC,OAAQ,EACR,KAAM,GACN,OAAQ,QACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,yEACb,eAAgB,gCAChB,OAAQ,SAGR,QAAS,GACT,QAAS,EACX,CAAC,EAEK,GAAa,EAAiB,CAClC,OAAQ,EACR,KAAM,QACN,OAAQ,OACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,+DACb,eAAgB,oCAChB,OAAQ,SACR,QAAS,EACX,CAAC,EAGD,SAAgB,IAA4B,CAC1C,OAAO,EAAmB,CAAC,GAAW,EAAU,CAAC,CACnD,CCtLA,MAAa,EAAwB,eAGxB,EAAmB,0BAIhC,IAAI,EAAU,EAOd,SAAS,IAAiB,CACxB,OAAO,IAAI,SAAS,KAAK,UAAU,CAAE,MAAO,cAAe,KAAM,aAAc,CAAC,EAAG,CACjF,OAAQ,IACR,QAAS,CACP,eAAgB,mBAChB,gBAAiB,WACjB,cAAe,IACjB,CACF,CAAC,CACH,CAKA,SAASK,EAAK,EAAe,EAAS,IAAe,CACnD,OAAO,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACxC,SACA,QAAS,CAAE,eAAgB,mBAAoB,gBAAiB,UAAW,CAC7E,CAAC,CACH,CAEA,SAASC,EAAS,EAAkD,CAClE,OAAO,OAAO,GAAU,YAAY,GAAkB,CAAC,MAAM,QAAQ,CAAK,CAC5E,CAMA,eAAe,EACb,EACA,EACmF,CAEnF,IAAM,EAAO,MAAM,EAAe,EAAKF,IAAc,EACrD,GAAI,aAAc,EAAM,MAAO,CAAE,QAASC,EAAK,CAAE,MAAO,gBAAiB,EAAG,GAAG,CAAE,EACjF,IAAI,EACJ,GAAI,CACF,EAAQ,KAAK,MAAM,EAAK,IAAI,CAC9B,MAAQ,CACN,MAAO,CAAE,QAASA,EAAK,CAAE,MAAO,cAAe,EAAG,GAAG,CAAE,CACzD,CACA,IAAM,EAAU,IAAI,IAAI,CAAC,QAAS,GAAG,CAAS,CAAC,EAC/C,GAAI,CAACC,EAAS,CAAK,GAAK,OAAO,KAAK,CAAK,CAAC,CAAC,KAAM,GAAQ,CAAC,EAAQ,IAAI,CAAG,CAAC,EACxE,MAAO,CAAE,QAASD,EAAK,CAAE,MAAO,cAAe,EAAG,GAAG,CAAE,EAEzD,IAAM,EAAQ,GAAkB,EAAM,KAAK,EAI3C,OAFI,IAAU,KACL,CAAE,QAASA,EAAK,CAAE,MAAO,gBAAiB,KAAM,eAAgB,EAAG,GAAG,CAAE,EAC1E,CAAE,QAAO,KAAM,CAAM,CAC9B,CAMA,MAAa,GAAyC,MAAO,EAAK,IAAQ,CACxE,IAAM,EAAU,MAAM,EAAY,EAAK,CAAC,CAAC,EACzC,GAAI,YAAa,EAAS,OAAO,EAAQ,QACzC,GAAM,CAAE,SAAU,EACZ,CAAC,EAAO,EAAM,GAAa,MAAM,QAAQ,IAAI,CACjD,GAAwB,EAAI,IAAK,CAAK,EACtC,EAAuB,EAAI,IAAK,EAAO,CAAE,MAAO,EAAkB,OAAQ,CAAE,CAAC,EAC7E,EAA0B,EAAI,GAAG,CACnC,CAAC,EACK,EAAQ,MAAM,EAClB,EAAI,IACJ,EAAK,IAAK,GAAQ,EAAI,MAAM,CAC9B,EACM,EAAwB,EAAK,IAAK,IAAS,CAC/C,GAAI,EAAI,GACR,OAAQ,EAAI,OACZ,SAAU,EAAM,IAAI,EAAI,MAAM,GAAK,KACnC,UAAW,EAAI,UACf,SAAU,EAAI,QAChB,EAAE,EAMF,OALA,EAAI,QAAQ,CACV,OAAQ,uBACR,WAAY,kBACZ,SAAU,CAAE,MAAO,EAAO,MAAO,CAAC,GAAG,IAAI,IAAI,EAAK,IAAK,GAAQ,EAAI,MAAM,CAAC,CAAC,EAAG,WAAU,CAC1F,CAAC,EACMA,EAAK,CAAE,QAAO,QAAO,YAAW,UAAW,CAAmB,CAAC,CACxE,EAMM,GAAU,CAAC,MAAO,MAAM,EAe9B,SAASE,GAAQ,EAAoC,EAA6C,CAChG,IAAI,EAAgB,EAAU,MAC9B,IAAK,IAAM,KAAO,EAAM,CACtB,GAAI,CAACD,EAAS,CAAI,EAAG,OACrB,EAAO,EAAK,EACd,CACA,OAAO,OAAO,GAAS,UAAY,IAAS,GAAK,EAAO,IAAA,EAC1D,CAEA,eAAeE,GAAW,EAAiD,CAGzE,IAAM,EAAY,MAAM,EAAiB,GAAU,IAAI,EACjD,GAAQ,EAAyB,IAAqBD,GAAQ,EAAW,CAAI,GAAK,EACxF,MAAO,CACL,KAAM,EAAK,CAAC,UAAW,MAAO,MAAM,EAAG,MAAM,EAC7C,SAAU,EAAK,CAAC,UAAW,MAAO,UAAU,EAAG,gBAAgB,EAC/D,KAAM,EAAK,CAAC,UAAW,MAAO,MAAM,EAAG,MAAM,EAC7C,OAAQ,EAAK,CAAC,UAAW,MAAO,QAAQ,EAAG,QAAQ,EACnD,IAAK,EAAK,CAAC,cAAe,KAAK,EAAG,KAAK,EACvC,GAAI,EAAK,CAAC,cAAe,IAAI,EAAG,IAAI,EACpC,YAAa,EAAK,CAAC,UAAW,aAAa,EAAG,SAAS,EACvD,SAAU,CACR,IAAK,EAAK,CAAC,mBAAoB,KAAK,EAAG,KAAK,EAC5C,KAAM,EAAK,CAAC,mBAAoB,MAAM,EAAG,MAAM,EAC/C,SAAU,EAAK,CAAC,mBAAoB,UAAU,EAAG,UAAU,CAC7D,CACF,CACF,CAGA,SAAS,EAAQ,EAAuB,EAAqB,CAC3D,IAAM,EAAQ,EAAI,OAAO,EAAI,EAAE,MAC/B,OAAO,IAAU,IAAA,IAAa,IAAU,GAAK,EAAQ,CACvD,CAGA,SAAS,GAAe,EAAwD,CAC9E,IAAM,EAAU,IAAI,IACpB,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,EAAI,IAAI,EAAG,CACnD,IAAM,EAAQ,EAAQ,EAAK,CAAG,EAC9B,EAAQ,IAAI,EAAO,CAAC,GAAI,EAAQ,IAAI,CAAK,GAAK,CAAC,EAAI,CAAK,CAAC,CAC3D,CACA,OAAO,CACT,CAEA,SAAS,GAAK,EAAiD,EAAmC,CAChG,GAAI,IAAW,IAAA,IAAa,EAAO,SAAW,EAAG,MAAO,GACxD,IAAM,EAAQ,EAAO,IAAK,GACxB,OAAO,GAAU,UAAa,EAAQ,EAAK,IAAM,EAAK,GAAM,CAC9D,EACM,EAAO,EAAM,GACnB,OAAO,EAAM,SAAW,GAAK,IAAS,IAAA,GAAY,EAAO,EAAM,KAAK,KAAK,CAC3E,CAGA,SAASE,GAAQ,EAAqB,CACpC,OAAO,EAAI,MAAM,EAAG,EAAE,CAAC,CAAC,QAAQ,IAAK,GAAG,CAC1C,CAGA,SAAS,GACP,EACA,EACA,EACA,EACmD,CACnD,OAAO,GACJ,EAAQ,IAAU,EAAuB,EAAK,EAAO,CAAE,QAAO,SAAQ,OAAM,CAAC,EAC9E,CACE,SAAU,EACV,QAAS,EACT,GAAI,IAAgB,IAAA,IAAa,CAAE,aAAY,CACjD,CACF,CACF,CAEA,MAAa,GAAyC,MAAO,EAAK,IAAQ,CACxE,IAAM,EAAU,MAAM,EAAY,EAAK,CAAC,SAAU,UAAU,CAAC,EAC7D,GAAI,YAAa,EAAS,OAAO,EAAQ,QACzC,GAAM,CAAE,QAAO,QAAS,EAClB,EAAmC,GAAQ,KAAM,GAAS,IAAS,EAAK,MAAM,EACpF,GAAI,IAAW,IAAA,GAAW,OAAOJ,EAAK,CAAE,MAAO,gBAAiB,EAAG,GAAG,EACtE,IAAM,EACJ,OAAO,EAAK,UAAa,UAAY,EAAK,SAAS,QAAU,GAAK,EAAK,SAAW,EAAI,OAClF,EAAM,EAAI,IAOhB,GAAI,GAAW,EAAkB,OAAO,GAAK,EAC7C,GAAW,EACX,IAAM,EAAQ,IAAI,KACZ,EAA2B,CAAC,EAC9B,EAAY,GACZ,EACJ,GAAI,CACF,UAAW,IAAM,KAAO,GAAW,EAAK,EAAO,MAAa,CAC1D,EAAY,EACd,CAAC,EACC,EAAK,KAAK,CAAG,EAEf,EAAQ,MAAM,EAAU,EAAK,CAAC,GAAG,IAAI,IAAI,EAAK,IAAK,GAAQ,EAAI,MAAM,CAAC,CAAC,CAAC,CAC1E,QAAU,CACR,GACF,CACI,GAEF,EAAI,OAAO,KACT,CAAE,QAAS,EAAK,MAAO,EACvB,mFACF,EAIF,IAAM,MAAoB,CACxB,EAAI,QAAQ,CACV,OAAQ,uBACR,WAAY,kBACZ,SAAU,CACR,MAAO,EAAK,OACZ,MAAO,CAAC,GAAG,IAAI,IAAI,EAAK,IAAK,GAAQ,EAAI,MAAM,CAAC,CAAC,EACjD,SACA,WACF,CACF,CAAC,CACH,EAEA,GAAI,IAAW,OAAQ,CACrB,IAAM,EAAO,KAAK,UAChB,CACE,WAAY,EAAM,YAAY,EAC9B,YACA,YAAa,EAAK,IAAK,IAAS,CAC9B,KAAM,EAAM,IAAI,EAAI,MAAM,GAAK,KAC/B,WAAY,EAAI,UAChB,KAAM,EAAI,SACV,SAAU,EAAI,OACd,OAAQ,EAAI,OACZ,QAAS,OAAO,QAAQ,EAAI,IAAI,CAAC,CAAC,KAAK,CAAC,EAAK,MAAY,CACvD,MAAO,EAAQ,EAAK,CAAG,EACvB,MACA,OAAQ,CACV,EAAE,EACF,QAAS,OAAO,OAAO,EAAI,OAAO,CACpC,EAAE,CACJ,EACA,KACA,CACF,EAEA,OADA,EAAM,EACC,IAAI,SAAS,EAAM,CACxB,OAAQ,IACR,QAAS,CACP,eAAgB,kCAChB,sBAAuB,yBAAyB,EAAY,iBAAiB,CAAC,CAAC,QAAQ,SAAU,OAAO,EAAE,GAC1G,gBAAiB,oBACjB,yBAA0B,WACzB,GAAmB,OAAO,CAAS,CACtC,CACF,CAAC,CACH,CAEA,IAAM,EAAO,MAAMG,GAAW,CAAQ,EAChC,EAAmB,CAAC,EACpB,EAAO,IAAI,IACjB,IAAK,IAAM,KAAO,EAChB,IAAK,IAAM,KAAO,OAAO,KAAK,EAAI,IAAI,EAAG,CACvC,IAAM,EAAQ,EAAQ,EAAK,CAAG,EAC1B,EAAK,IAAI,CAAK,IAClB,EAAK,IAAI,CAAK,EACd,EAAO,KAAK,CAAK,EACnB,CAEF,IAAM,EAAM,MAAM,OACE,CAChB,SAAU,EAAY,iBAAiB,EACvC,UAAW,EAAgB,CAAQ,EACnC,OAAQ,CAAC,EAAK,KAAM,EAAK,SAAU,EAAK,KAAM,EAAK,OAAQ,GAAG,CAAM,EACpE,KAAM,EAAK,IAAK,GAAQ,CACtB,IAAM,EAAU,GAAe,CAAG,EAClC,MAAO,CACL,EAAM,IAAI,EAAI,MAAM,GAAK,EAAK,YAC9BC,GAAQ,EAAI,SAAS,EACrB,EAAI,UAAY,GAChB,EAAK,SAAS,EAAI,SAAW,EAAI,OACjC,GAAG,EAAO,IAAK,GAAU,GAAK,EAAQ,IAAI,CAAK,EAAG,CAAI,CAAC,CACzD,CACF,CAAC,CACH,EACF,EAKA,OAJI,EAAI,KACN,EAAM,EACN,EAAI,QAAQ,IAAI,EAAkB,OAAO,CAAS,CAAC,GAE9C,CACT,EAMa,GAAwC,MAAO,EAAK,IAAQ,CACvE,IAAM,EAAU,MAAM,EAAY,EAAK,CAAC,CAAC,EACzC,GAAI,YAAa,EAAS,OAAO,EAAQ,QAGzC,IAAM,EAAS,MAAM,GAAwB,EAAI,IAAK,EAAQ,KAAK,EAC7D,EAAY,MAAM,EAA0B,EAAI,GAAG,CAAC,CAAC,UAAY,IAAI,EAa3E,OAZA,EAAI,QAAQ,CACV,OAAQ,sBACR,WAAY,kBAEZ,SAAU,CACR,MAAO,EAAO,OACd,MAAO,CAAC,GAAG,EAAO,OAAO,EACzB,WAAY,EAAO,KACnB,OAAQ,EAAO,OACf,WACF,CACF,CAAC,EACMJ,EACL,CACE,OAAQ,EAAO,OACf,MAAO,EAAO,QAAQ,OACtB,KAAM,EAAO,KACb,OAAQ,EAAO,OACf,WACF,EACA,EAAO,OAAS,IAAM,GACxB,CACF,EAMM,GAAc,EAAiB,CACnC,OAAQ,EACR,KAAM,SACN,OAAQ,OACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAEhC,YAAa,sEACb,eAAgB,uCAChB,OAAQ,SACR,QAAS,EACX,CAAC,EAEKK,GAAc,EAAiB,CACnC,OAAQ,EACR,KAAM,SACN,OAAQ,OACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAEhC,YAAa,uEACb,eAAgB,uCAChB,OAAQ,SACR,QAAS,EACX,CAAC,EAEK,GAAa,EAAiB,CAClC,OAAQ,EACR,KAAM,GACN,OAAQ,SACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAEhC,YAAa,8EACb,eAAgB,sCAIhB,OAAQ,CAAE,OAAQ,eAAgB,EAClC,QAAS,EACX,CAAC,EAGD,SAAgB,IAA8B,CAC5C,OAAO,EAAmB,CAAC,GAAaA,GAAa,EAAU,CAAC,CAClE,CC1ZA,MAAa,EAA2B,mBAKxC,SAAS,EAAK,EAAe,EAAS,IAAe,CACnD,OAAO,IAAI,SAAS,KAAK,UAAU,CAAI,EAAG,CACxC,SACA,QAAS,CAAE,eAAgB,mBAAoB,gBAAiB,UAAW,CAC7E,CAAC,CACH,CAEA,MAAM,EAAU,kEAEhB,SAAS,EAAS,EAAkD,CAClE,OAAO,OAAO,GAAU,YAAY,GAAkB,CAAC,MAAM,QAAQ,CAAK,CAC5E,CAGA,SAAS,GAAe,EAAoC,CAC1D,OAAO,MAAM,QAAQ,CAAK,CAC5B,CAMA,SAAS,EACP,EACA,EACuE,CACvE,GAAI,CAAC,EAAS,CAAK,EAAG,OAAO,KAC7B,IAAM,EAAU,IAAI,IAAI,CAAC,OAAQ,MAAO,GAAG,CAAS,CAAC,EACrD,GAAI,OAAO,KAAK,CAAK,CAAC,CAAC,KAAM,GAAQ,CAAC,EAAQ,IAAI,CAAG,CAAC,EAAG,OAAO,KAChE,GAAM,CAAE,OAAM,OAAQ,EAEtB,GADI,OAAO,GAAS,UAAY,CAAC,EAAQ,KAAK,CAAI,GAC9C,CAAC,GAAe,CAAG,GAAK,EAAI,SAAW,GAAK,EAAI,OAAS,EAAqB,OAAO,KACzF,IAAM,EAAQ,EAAI,OAAQ,GAAqB,OAAO,GAAO,UAAY,EAAQ,KAAK,CAAE,CAAC,EAEzF,OADI,EAAM,SAAW,EAAI,OAClB,CAAE,OAAM,IAAK,EAAO,KAAM,CAAM,EADC,IAE1C,CAGA,eAAe,EAAS,EAAmE,CAEzF,IAAM,EAAO,MAAM,EAAe,EAAK,IAAc,EACrD,GAAI,aAAc,EAAM,MAAO,CAAE,QAAS,EAAK,CAAE,MAAO,gBAAiB,EAAG,GAAG,CAAE,EACjF,IAAM,EAAM,EAAK,KACjB,GAAI,CAEF,MAAO,CAAE,MADc,KAAK,MAAM,CACrB,CAAE,CACjB,MAAQ,CACN,MAAO,CAAE,QAAS,EAAK,CAAE,MAAO,cAAe,EAAG,GAAG,CAAE,CACzD,CACF,CAQA,eAAe,EACb,EACA,EACiD,CACjD,IAAM,EAAkD,MAAM,EAC3D,gBAAgB,CAAI,CAAC,CACrB,SAAS,CAAE,EAEd,OADI,GAAQ,KAAkC,KACvC,CAAE,KAAM,OAAO,EAAI,MAAS,SAAW,EAAI,KAAO,OAAQ,KAAM,EAAI,IAAK,CAClF,CAMA,MAAa,GAAmC,MAAO,EAAK,IAAQ,CAClE,IAAM,EAAS,IAAI,IAAI,EAAI,GAAG,CAAC,CAAC,aAAa,IAAI,MAAM,GAAK,GAE5D,GADI,CAAC,EAAQ,KAAK,CAAM,GACnB,MAAM,EAAS,EAAI,IAAK,CAAM,IAAO,KAAM,OAAO,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EACvF,IAAM,GAAU,MAAM,GAAiB,EAAI,IAAK,CAAC,CAAM,CAAC,EAAA,CAAG,IAAI,CAAM,EACrE,OAAO,EAAK,CAAE,MAAO,GAAQ,OAAS,EAAG,MAAO,GAAQ,OAAS,CAAE,CAAC,CACtE,EAMa,GAAqC,MAAO,EAAK,IAAQ,CACpE,IAAM,EAAO,MAAM,EAAS,CAAG,EAC/B,GAAI,YAAa,EAAM,OAAO,EAAK,QACnC,IAAM,EAAS,EAAY,EAAK,MAAO,CAAC,QAAQ,CAAC,EAC3C,EAAS,GAAQ,KAAK,OAC5B,GAAI,IAAW,MAAQ,CAAC,GAAmB,CAAM,EAC/C,OAAO,EAAK,CAAE,MAAO,iEAAkE,EAAG,GAAG,EAE/F,GAAM,CAAE,QAAS,EACjB,GAAK,MAAM,EAAS,EAAI,IAAK,CAAI,IAAO,KAAM,OAAO,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAErF,IAAM,EAAU,MAAM,GAAoB,EAAI,IAAK,EAAM,EAAO,IAAK,CAAM,EAO3E,OANA,EAAI,QAAQ,CACV,OAAQ,2BACR,WAAY,OACZ,SAAU,EACV,SAAU,CAAE,SAAQ,MAAO,CAAQ,CACrC,CAAC,EACM,EAAK,CAAE,SAAQ,CAAC,CACzB,EAMa,GAAkC,MAAO,EAAK,IAAQ,CACjE,IAAM,EAAO,MAAM,EAAS,CAAG,EAC/B,GAAI,YAAa,EAAM,OAAO,EAAK,QACnC,IAAM,EAAS,EAAY,EAAK,MAAO,CAAC,CAAC,EACzC,GAAI,IAAW,KAAM,OAAO,EAAK,CAAE,MAAO,sCAAuC,EAAG,GAAG,EACvF,GAAM,CAAE,QAAS,EACjB,GAAK,MAAM,EAAS,EAAI,IAAK,CAAI,IAAO,KAAM,OAAO,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAErF,IAAM,EAAU,MAAM,GAAkB,EAAI,IAAK,EAAM,EAAO,GAAG,EAOjE,OANA,EAAI,QAAQ,CACV,OAAQ,2BACR,WAAY,OACZ,SAAU,EACV,SAAU,CAAE,MAAO,CAAQ,CAC7B,CAAC,EACM,EAAK,CAAE,SAAQ,CAAC,CACzB,EAiBM,EAA2B,CAC/B,SAAU,iBACV,KAAM,OACN,OAAQ,SACR,IAAK,MACL,GAAI,KACJ,QAAS,gBACT,SAAU,CAAE,IAAK,MAAO,KAAM,OAAQ,SAAU,UAAW,CAC7D,EAGA,SAAS,GAAQ,EAAoC,EAA6C,CAChG,IAAI,EAAgB,EAAU,MAC9B,IAAK,IAAM,KAAO,EAAM,CACtB,GAAI,CAAC,EAAS,CAAI,EAAG,OACrB,EAAO,EAAK,EACd,CACA,OAAO,OAAO,GAAS,UAAY,IAAS,GAAK,EAAO,IAAA,EAC1D,CAEA,eAAe,EAAW,EAAiD,CAGzE,IAAM,EAAY,MAAM,EAAiB,GAAU,IAAI,EACjD,GAAQ,EAAyB,IAAqB,GAAQ,EAAW,CAAI,GAAK,EACxF,MAAO,CACL,SAAU,EAAK,CAAC,cAAe,MAAO,UAAU,EAAG,EAAa,QAAQ,EACxE,KAAM,EAAK,CAAC,cAAe,MAAO,MAAM,EAAG,EAAa,IAAI,EAC5D,OAAQ,EAAK,CAAC,cAAe,MAAO,QAAQ,EAAG,EAAa,MAAM,EAClE,IAAK,EAAK,CAAC,cAAe,KAAK,EAAG,EAAa,GAAG,EAClD,GAAI,EAAK,CAAC,cAAe,IAAI,EAAG,EAAa,EAAE,EAC/C,QAAS,EAAK,CAAC,cAAe,cAAc,EAAG,EAAa,OAAO,EACnE,SAAU,CACR,IAAK,EAAK,CAAC,mBAAoB,KAAK,EAAG,KAAK,EAC5C,KAAM,EAAK,CAAC,mBAAoB,MAAM,EAAG,MAAM,EAC/C,SAAU,EAAK,CAAC,mBAAoB,UAAU,EAAG,UAAU,CAC7D,CACF,CACF,CAGA,SAAS,GAAQ,EAAqB,CACpC,OAAO,EAAI,MAAM,EAAG,EAAE,CAAC,CAAC,QAAQ,IAAK,GAAG,CAC1C,CAEA,SAAS,GAAU,EAAqC,EAAmC,CAGzF,OAFI,IAAU,IAAA,GAAkB,GAC5B,OAAO,GAAU,UAAkB,EAAQ,EAAK,IAAM,EAAK,GACxD,CACT,CAgFA,MAAM,GAAc,EAAiB,CACnC,OAAQ,EACR,KAAM,SACN,OAAQ,MACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,8EACb,eAAgB,gCAChB,OAAQ,SACR,QAAS,MAvF2C,EAAK,IAAQ,CACjE,IAAM,EAAM,IAAI,IAAI,EAAI,GAAG,EACrB,EAAS,EAAI,aAAa,IAAI,MAAM,GAAK,GAC/C,GAAI,CAAC,EAAQ,KAAK,CAAM,EAAG,OAAO,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAClE,IAAM,EAAO,MAAM,EAAS,EAAI,IAAK,CAAM,EAC3C,GAAI,IAAS,KAAM,OAAO,EAAK,CAAE,MAAO,WAAY,EAAG,GAAG,EAO1D,IAAM,EAAU,CACd,GAAG,GAAwB,GAAS,EAAI,aAAa,IAAI,CAAI,CAAC,EAC9D,MAAO,IAAI,IACb,EACM,EAAW,EAAI,aAAa,IAAI,UAAU,GAAK,EAAI,OACnD,EAAU,EAAkB,EAAK,IAAI,EACrC,EAAM,EAAI,IACV,EAAS,GACb,GACG,EAAQ,IAAU,GAAmB,EAAK,EAAQ,EAAS,CAAE,QAAO,QAAO,CAAC,EAG7E,CAAE,SAAU,EAAsB,GAAI,IAAgB,IAAA,IAAa,CAAE,aAAY,CAAG,CACtF,EAEF,OAAO,EAAa,SAAgC,CAClD,EAAI,QAAQ,CACV,OAAQ,2BACR,WAAY,OACZ,SAAU,EACV,SAAU,CAAE,SAAU,GAAuB,CAAO,CAAC,CAAC,KAAO,CAAE,CACjE,CAAC,EACD,IAAM,EAAO,MAAM,EAAW,CAAQ,EAIhC,EAAU,IAAI,IACpB,UAAW,IAAM,KAAO,EAAM,EAAG,EAAsB,EAAS,CAAC,CAAG,EAAG,CAAO,EAC9E,IAAM,EAAU,GAAkB,EAAS,CAAO,EAElD,MAAO,CACL,SAAU,EAAY,EAAK,IAAI,EAC/B,UAAW,EAAgB,CAAQ,EACnC,OAAQ,CACN,EAAK,SACL,EAAK,KACL,EAAK,OACL,GAAG,EAAQ,IAAK,GACd,EAAO,QAAU,GAAG,EAAO,MAAM,IAAI,EAAK,QAAQ,GAAK,EAAO,KAChE,CACF,EACA,MAAO,iBAAmB,CACxB,IAAM,EAAO,EAAO,GAAY,CAE9B,EAAI,OAAO,KACT,CAAE,SAAQ,SAAQ,EAClB,uFACF,CACF,CAAC,EACD,UAAW,IAAM,KAAO,EACtB,KAAM,CACJ,GAAQ,EAAI,SAAS,EACrB,EAAI,UAAY,GAChB,EAAK,SAAS,EAAI,SAAW,EAAI,OACjC,GAAG,EAAQ,IAAK,GAAW,GAAU,EAAI,KAAK,EAAO,KAAM,CAAI,CAAC,CAClE,CAEJ,EAAA,CAAG,CACL,CACF,CAAC,CACH,CAgBA,CAAC,EAEK,GAAe,EAAiB,CACpC,OAAQ,EACR,KAAM,UACN,OAAQ,MACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,sEACb,eAAgB,iCAChB,OAAQ,SACR,QAAS,EACX,CAAC,EAEK,GAAc,EAAiB,CACnC,OAAQ,EACR,KAAM,GACN,OAAQ,QACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,2EACb,eAAgB,gCAChB,OAAQ,SACR,QAAS,EACX,CAAC,EAEK,GAAc,EAAiB,CACnC,OAAQ,EACR,KAAM,GACN,OAAQ,SACR,WAAY,EACZ,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,wDACb,eAAgB,gCAChB,OAAQ,SAER,QAAS,GACT,QAAS,EACX,CAAC,EAGD,SAAgB,IAAiC,CAC/C,OAAO,EAAmB,CAAC,GAAa,GAAc,GAAa,EAAW,CAAC,CACjF,CClVA,MAAM,EAAiB,GAAG,EAAK,KAAK,OAE9B,GAAiD,KAAO,KAErD,MADW,OAAO,+BAAA,CACd,cAAc,CAAK,EAE1B,GAA8D,KAAO,KAElE,MADW,OAAO,+BAAA,CACd,eAAe,CAAK,EAE3B,GAA+D,KAAO,KAEnE,MADW,OAAO,+BAAA,CACd,gBAAgB,CAAK,EAG5B,GAAuD,KAAO,KAE3D,MADW,OAAO,+BAAA,CACd,oBAAoB,CAAK,EAIzB,EAAsB,kBAGtB,EAA2B,aAExC,SAAgB,IAAc,CAC5B,OAAO,EAAa,CAClB,KAAM,qBAKN,SAAU,CACR,uBACA,wBACA,6BACA,oBACF,EACA,OAAQ,CAIN,SAAU,CAAC,EAAM,CAAc,EAI/B,OAAQ,CAAE,GAAG,EAAY,EAKzB,OAAQ,CAAC,GAAG,EAAY,EAAG,GAAG,GAAiB,EAAG,GAAG,GAAc,EAAG,GAAG,GAAY,CAAC,EAOtF,KAAM,KAAO,IAAQ,CACnB,GAAI,CAAC,EAAI,QAAQ,IAAI,oBAAoB,EAAG,OAC5C,GAAM,CAAE,YAAW,eAAgB,MAAM,OAAO,6BAChD,EAGE,EAAU,CAAE,KAAM,EAAqB,SAAU,CAAyB,CAAC,EAC3E,SAAY,CACV,GAAM,CAAE,WAAU,kBAAmB,MAAM,OAAO,qBAC5C,CAAE,qBAAsB,MAAM,OAAO,sBAKrC,EAAO,MAAM,MACjB,MAAqB,EAAkB,CAAG,EAAG,CAAE,MAAK,MAAO,CAAoB,CAAC,CAClF,EACM,EAAU,EAAK,QAAQ,EAAK,IAAQ,EAAM,EAAI,QAAS,CAAC,EAE9D,EAAI,OAAO,KACT,CACE,MAAO,EAAK,OACZ,UACA,WAAY,EAAK,OAAQ,GAAQ,EAAI,UAAU,CAAC,CAAC,OACjD,WAAY,EAAK,QAAQ,EAAK,IAAQ,EAAM,EAAI,WAAY,CAAC,CAC/D,EACA,uDACF,CACF,CACF,EAGA,GAAM,CAAE,SAAU,GAAkB,MAAM,OAAO,qBAC3C,CAAE,kBAAiB,oBAAqB,MAAM,OAAO,iCAC3D,EAAY,EAAkB,KAAO,IAAQ,CAC3C,MAAM,EAAc,SAAY,CAC9B,IAAI,EAAgB,KACpB,GAAI,CACF,GAAM,CAAE,0BAA2B,MAAM,OAAO,qCAChD,EAAgB,EAAuB,CAAC,CAAC,aAC3C,MAAQ,CACN,EAAI,OAAO,KACT,CAAE,QAAS,mBAAoB,EAC/B,4DACF,CACF,CACA,MAAM,EAAgB,EAAI,QAAS,CAAE,MAAK,OAAQ,EAAI,OAAQ,eAAc,CAAC,CAC/E,CAAC,CACH,CAAC,CACH,CACF,EACA,YAAa,CAEX,gBAAiB,CAAC,GAAG,GAAkB,EAAc,EACrD,0BAA2B,CAAC,EAAe,EAI3C,sBAAuB,EACzB,EACA,OAAQ,CACN,SAAU,EAGV,cAAe,CAAC,GAAG,EAAc,EACjC,SAAU,CAAC,EAAa,EACxB,SAAU,CACZ,EACA,QAAS,CAEP,oBAAqB,EAAG,EAAK,MAAO,EAA4B,EAChE,MAAO,CACL,iBACA,kBACA,mBACA,sBACF,EACA,QAAS,CACP,CACE,GAAI,QACJ,MAAO,UACP,MAAO,QACP,KAAM,eACN,SAAU,iBAGV,SAAU,CACZ,CACF,EACA,cAAe,CACb,CAEE,KAAM,QACN,QAAS,gBACT,KAAM,CAIJ,SAAU,EACV,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,8CACb,eAAgB,yBAIhB,OAAQ,QACV,CACF,EACA,CAIE,KAAM,gBACN,QAAS,sBACT,KAAM,CAIJ,SAAU,EACV,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,2EACb,eAAgB,4BAKhB,OAAQ,QACV,CACF,EACA,CAEE,KAAM,aACN,QAAS,iBACT,KAAM,CACJ,SAAU,EACV,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,6BACb,eAAgB,2BAChB,OAAQ,QACV,CACF,EACA,CAGE,KAAM,yBACN,QAAS,kBACT,KAAM,CACJ,SAAU,EACV,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,qDACb,eAAgB,gCAGhB,OAAQ,QACV,CACF,EAMA,CAAE,KAAM,OAAQ,QAAS,eAAgB,EACzC,CAAE,KAAM,YAAa,QAAS,gBAAiB,CACjD,CACF,CACF,CAAC,CACH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/forms-ui",
3
- "version": "0.101.0",
3
+ "version": "0.103.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "description": "Admin half of @murumets-ee/forms: the form canvas registration, the Forms list, the New form dialog and the plugin.",
@@ -26,14 +26,15 @@
26
26
  "dist"
27
27
  ],
28
28
  "dependencies": {
29
- "@murumets-ee/admin-ui": "0.101.0",
30
- "@murumets-ee/blocks": "0.101.0",
31
- "@murumets-ee/content": "0.101.0",
32
- "@murumets-ee/core": "0.101.0",
33
- "@murumets-ee/forms": "0.101.0",
34
- "@murumets-ee/queue": "0.101.0",
35
- "@murumets-ee/ui": "0.101.0",
36
- "@murumets-ee/settings": "0.101.0"
29
+ "@murumets-ee/admin-ui": "0.103.0",
30
+ "@murumets-ee/content": "0.103.0",
31
+ "@murumets-ee/core": "0.103.0",
32
+ "@murumets-ee/forms": "0.103.0",
33
+ "@murumets-ee/notifications": "0.103.0",
34
+ "@murumets-ee/queue": "0.103.0",
35
+ "@murumets-ee/settings": "0.103.0",
36
+ "@murumets-ee/blocks": "0.103.0",
37
+ "@murumets-ee/ui": "0.103.0"
37
38
  },
38
39
  "peerDependencies": {
39
40
  "next": ">=16.0.0 <17",
@@ -68,8 +69,8 @@
68
69
  "bundleBudget": {
69
70
  "dist/index.mjs": 20480,
70
71
  "dist/canvas-header-action.mjs": 2048,
71
- "dist/pages.mjs": 12288,
72
- "dist/plugin.mjs": 16384
72
+ "dist/pages.mjs": 14336,
73
+ "dist/plugin.mjs": 20480
73
74
  },
74
75
  "scripts": {
75
76
  "build": "tsdown",