@elytracms/forms 0.0.98 → 0.0.100

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.
@@ -32,9 +32,10 @@ export function formPayload(entries, options = {}) {
32
32
  }
33
33
  values.set(name, [...(values.get(name) ?? []), value]);
34
34
  }
35
- const data = {};
36
- for (const [name, list] of values)
37
- data[name] = list.length === 1 ? list[0] : list;
35
+ // EC-704: every posted name becomes an OWN entry — `Object.fromEntries` DEFINES each property.
36
+ // An assignment (`data[name] = …`) would call the prototype setter for a field named
37
+ // `__proto__` and lose that answer silently; kept, the name rule refuses it visibly below.
38
+ const data = Object.fromEntries([...values].map(([name, list]) => [name, list.length === 1 ? list[0] : list]));
38
39
  if (options.sourceUrl)
39
40
  data[FORMS_SOURCE_KEY] = options.sourceUrl;
40
41
  data[FORMS_HONEYPOT_KEY] = honeypotValue;
@@ -1 +1 @@
1
- {"version":3,"file":"submit.js","sourceRoot":"","sources":["../../src/next/submit.ts"],"names":[],"mappings":"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","sourcesContent":["import { ingestUploadSha256 } from '@elytracms/core/ingest'\nimport {\n DEFAULT_HONEYPOT_FIELD,\n FORMS_HONEYPOT_KEY,\n FORMS_RESERVED_PREFIX,\n FORMS_SOURCE_KEY,\n FORMS_TARGET_FIELD,\n formFieldNameMessage,\n formFieldNameProblem,\n} from '../shared/submission'\n\n/**\n * EC-613 — a form post as the ingest seam's calls (EC-617): one `upload` per file (the route\n * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one\n * `submission` that claims the stored files. The ONE flow, with injected transports, so the\n * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send\n * exactly the same calls — and EC-685: hash every file in the same place, so each `upload`\n * declares its bytes' SHA-256 (the claim binds a stored file only when its digest is the\n * declared one).\n */\n\n/** A posted value: text, or a file (a `File` / `Blob` with a name). */\ntype PostedValue = string | Blob\n\n/** The form's fields as the ingest payload carries them, and the files to upload. */\nexport interface FormPayload {\n data: Record<string, string | string[]>\n files: { field: string; file: Blob; name: string }[]\n}\n\nexport interface FormPayloadOptions {\n /** The honeypot field the form renders (default `website`); its value goes to `elytra:honeypot`. */\n honeypot?: string\n /** The page the form was submitted from (`$source`). */\n sourceUrl?: string\n}\n\nfunction isFile(value: PostedValue): value is Blob {\n return typeof value !== 'string'\n}\n\n/**\n * Read a form's entries (pure): repeated names become a list, the honeypot moves to\n * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is\n * an upload whose NAME is the field's value (what the inbox and the mail list). A file input\n * left empty posts a nameless zero-byte part — it is skipped.\n */\nexport function formPayload(entries: Iterable<[string, PostedValue]>, options: FormPayloadOptions = {}): FormPayload {\n const honeypot = options.honeypot ?? DEFAULT_HONEYPOT_FIELD\n const values = new Map<string, string[]>()\n const files: FormPayload['files'] = []\n let honeypotValue = ''\n for (const [name, value] of entries) {\n if (name === FORMS_TARGET_FIELD) continue\n if (name === honeypot) {\n if (typeof value === 'string') honeypotValue = value\n continue\n }\n if (isFile(value)) {\n if (value.size === 0) continue\n const fileName = (value as Blob & { name?: string }).name || 'file'\n files.push({ field: name, file: value, name: fileName })\n values.set(name, [...(values.get(name) ?? []), fileName])\n continue\n }\n values.set(name, [...(values.get(name) ?? []), value])\n }\n const data: FormPayload['data'] = {}\n for (const [name, list] of values) data[name] = list.length === 1 ? list[0]! : list\n if (options.sourceUrl) data[FORMS_SOURCE_KEY] = options.sourceUrl\n data[FORMS_HONEYPOT_KEY] = honeypotValue\n return { data, files }\n}\n\n/** What a submit came to. */\nexport type SubmitResult =\n | { ok: true; id: string }\n | {\n ok: false\n /** Which call failed: an upload URL, the bytes, or the submission. */\n stage: 'upload' | 'storage' | 'submission'\n status: number\n error: string\n issues?: { path: (string | number)[]; message: string }[]\n }\n\n/** The two transports the flow runs over. */\nexport interface SubmitTransport {\n /** Send one ingest payload through the site's ingest route; resolve with its answer. */\n ingest(payload: Record<string, unknown>): Promise<Response>\n /** Send one file's bytes to an issued storage URL; resolve with the storage's answer (`{ storageId }`). */\n store(uploadUrl: string, file: Blob, contentType: string): Promise<Response>\n}\n\nasync function readJson(response: Response): Promise<Record<string, unknown>> {\n try {\n const parsed = (await response.json()) as unknown\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}\n } catch {\n return {}\n }\n}\n\nfunction failure(stage: 'upload' | 'storage' | 'submission', status: number, body: Record<string, unknown>, fallback: string): SubmitResult {\n return {\n ok: false,\n stage,\n status,\n error: typeof body['error'] === 'string' ? body['error'] : fallback,\n ...(Array.isArray(body['issues']) ? { issues: body['issues'] as { path: (string | number)[]; message: string }[] } : {}),\n }\n}\n\n/**\n * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file\n * uploaded first, then the submission claiming them. The first failure ends it and is\n * returned with its stage and status; nothing is retried here.\n *\n * EC-685: every file's SHA-256 is computed FIRST, here — the one hashing site of both flows —\n * and declared with its `upload`. A browser computes it only on a secure page (WebCrypto,\n * https or localhost); elsewhere the post is refused before anything is sent (stage `upload`,\n * status 400, the reason in `error`), never sent without the digest.\n */\nexport async function sendFormSubmission(input: { target: string; payload: FormPayload; transport: SubmitTransport }): Promise<SubmitResult> {\n const { target, payload, transport } = input\n // A name the envelope (or the forms addon) would refuse is refused here, before anything is\n // sent — with the same reason, as the same `['data', name]` issues the route's 400 carries.\n const names = new Set([...Object.keys(payload.data), ...payload.files.map((file) => file.field)])\n const issues = [...names].flatMap((name) => {\n if (name.startsWith(FORMS_RESERVED_PREFIX)) return []\n const problem = formFieldNameProblem(name)\n return problem ? [{ path: ['data', name] as (string | number)[], message: formFieldNameMessage(name, problem) }] : []\n })\n if (issues.length > 0) {\n return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues }\n }\n const digests: string[] = []\n for (const { file } of payload.files) {\n const digest = await ingestUploadSha256(file)\n if (!digest.ok) return { ok: false, stage: 'upload', status: 400, error: digest.message }\n digests.push(digest.sha256)\n }\n const uploads: { uploadId: string; storageId: string }[] = []\n for (const [index, { field, file }] of payload.files.entries()) {\n // The type the browser reports; an unknown one is declared as an opaque binary.\n const contentType = file.type || 'application/octet-stream'\n const sha256 = digests[index]!\n const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size, sha256 })\n const issuedBody = await readJson(issued)\n if (!issued.ok) return failure('upload', issued.status, issuedBody, 'The upload was refused.')\n const uploadId = issuedBody['uploadId']\n const uploadUrl = issuedBody['uploadUrl']\n if (typeof uploadId !== 'string' || typeof uploadUrl !== 'string') {\n return failure('upload', 502, {}, 'The upload answer carried no upload URL.')\n }\n const stored = await transport.store(uploadUrl, file, contentType)\n const storedBody = await readJson(stored)\n if (!stored.ok || typeof storedBody['storageId'] !== 'string') {\n return failure('storage', stored.ok ? 502 : stored.status, storedBody, 'The file could not be stored.')\n }\n uploads.push({ uploadId, storageId: storedBody['storageId'] })\n }\n const sent = await transport.ingest({\n kind: 'submission',\n target,\n data: payload.data,\n ...(uploads.length > 0 ? { uploads } : {}),\n })\n const body = await readJson(sent)\n if (!sent.ok) return failure('submission', sent.status, body, 'The submission was refused.')\n return { ok: true, id: typeof body['id'] === 'string' ? body['id'] : '' }\n}\n\n/**\n * Submit a form from the BROWSER (a client component's submit handler): its fields and files\n * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the\n * deployment's storage — so an upload is not bound by the site's function body limit.\n *\n * ```tsx\n * const result = await submitForm({ form: event.currentTarget, target: 'contact' })\n * ```\n */\nexport function submitForm(input: {\n form: HTMLFormElement | FormData\n target: string\n /** The site's ingest route (default `/api/ingest`). */\n endpoint?: string\n honeypot?: string\n /** Default: the current page. */\n sourceUrl?: string\n fetchImpl?: typeof fetch\n}): Promise<SubmitResult> {\n const fetchImpl = input.fetchImpl ?? fetch\n const formData = input.form instanceof FormData ? input.form : new FormData(input.form)\n const sourceUrl = input.sourceUrl ?? (typeof location === 'undefined' ? undefined : location.href)\n const payload = formPayload(formData.entries() as Iterable<[string, PostedValue]>, {\n ...(input.honeypot ? { honeypot: input.honeypot } : {}),\n ...(sourceUrl ? { sourceUrl } : {}),\n })\n const endpoint = input.endpoint ?? '/api/ingest'\n return sendFormSubmission({\n target: input.target,\n payload,\n transport: {\n ingest: (body) =>\n fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),\n store: (uploadUrl, file, contentType) =>\n fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),\n },\n })\n}\n"]}
1
+ {"version":3,"file":"submit.js","sourceRoot":"","sources":["../../src/next/submit.ts"],"names":[],"mappings":"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","sourcesContent":["import { ingestUploadSha256 } from '@elytracms/core/ingest'\nimport {\n DEFAULT_HONEYPOT_FIELD,\n FORMS_HONEYPOT_KEY,\n FORMS_RESERVED_PREFIX,\n FORMS_SOURCE_KEY,\n FORMS_TARGET_FIELD,\n formFieldNameMessage,\n formFieldNameProblem,\n} from '../shared/submission'\n\n/**\n * EC-613 — a form post as the ingest seam's calls (EC-617): one `upload` per file (the route\n * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one\n * `submission` that claims the stored files. The ONE flow, with injected transports, so the\n * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send\n * exactly the same calls — and EC-685: hash every file in the same place, so each `upload`\n * declares its bytes' SHA-256 (the claim binds a stored file only when its digest is the\n * declared one).\n */\n\n/** A posted value: text, or a file (a `File` / `Blob` with a name). */\ntype PostedValue = string | Blob\n\n/** The form's fields as the ingest payload carries them, and the files to upload. */\nexport interface FormPayload {\n data: Record<string, string | string[]>\n files: { field: string; file: Blob; name: string }[]\n}\n\nexport interface FormPayloadOptions {\n /** The honeypot field the form renders (default `website`); its value goes to `elytra:honeypot`. */\n honeypot?: string\n /** The page the form was submitted from (`$source`). */\n sourceUrl?: string\n}\n\nfunction isFile(value: PostedValue): value is Blob {\n return typeof value !== 'string'\n}\n\n/**\n * Read a form's entries (pure): repeated names become a list, the honeypot moves to\n * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is\n * an upload whose NAME is the field's value (what the inbox and the mail list). A file input\n * left empty posts a nameless zero-byte part — it is skipped.\n */\nexport function formPayload(entries: Iterable<[string, PostedValue]>, options: FormPayloadOptions = {}): FormPayload {\n const honeypot = options.honeypot ?? DEFAULT_HONEYPOT_FIELD\n const values = new Map<string, string[]>()\n const files: FormPayload['files'] = []\n let honeypotValue = ''\n for (const [name, value] of entries) {\n if (name === FORMS_TARGET_FIELD) continue\n if (name === honeypot) {\n if (typeof value === 'string') honeypotValue = value\n continue\n }\n if (isFile(value)) {\n if (value.size === 0) continue\n const fileName = (value as Blob & { name?: string }).name || 'file'\n files.push({ field: name, file: value, name: fileName })\n values.set(name, [...(values.get(name) ?? []), fileName])\n continue\n }\n values.set(name, [...(values.get(name) ?? []), value])\n }\n // EC-704: every posted name becomes an OWN entry — `Object.fromEntries` DEFINES each property.\n // An assignment (`data[name] = …`) would call the prototype setter for a field named\n // `__proto__` and lose that answer silently; kept, the name rule refuses it visibly below.\n const data: FormPayload['data'] = Object.fromEntries(\n [...values].map(([name, list]): [string, string | string[]] => [name, list.length === 1 ? list[0]! : list]),\n )\n if (options.sourceUrl) data[FORMS_SOURCE_KEY] = options.sourceUrl\n data[FORMS_HONEYPOT_KEY] = honeypotValue\n return { data, files }\n}\n\n/** What a submit came to. */\nexport type SubmitResult =\n | { ok: true; id: string }\n | {\n ok: false\n /** Which call failed: an upload URL, the bytes, or the submission. */\n stage: 'upload' | 'storage' | 'submission'\n status: number\n error: string\n issues?: { path: (string | number)[]; message: string }[]\n }\n\n/** The two transports the flow runs over. */\nexport interface SubmitTransport {\n /** Send one ingest payload through the site's ingest route; resolve with its answer. */\n ingest(payload: Record<string, unknown>): Promise<Response>\n /** Send one file's bytes to an issued storage URL; resolve with the storage's answer (`{ storageId }`). */\n store(uploadUrl: string, file: Blob, contentType: string): Promise<Response>\n}\n\nasync function readJson(response: Response): Promise<Record<string, unknown>> {\n try {\n const parsed = (await response.json()) as unknown\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}\n } catch {\n return {}\n }\n}\n\nfunction failure(stage: 'upload' | 'storage' | 'submission', status: number, body: Record<string, unknown>, fallback: string): SubmitResult {\n return {\n ok: false,\n stage,\n status,\n error: typeof body['error'] === 'string' ? body['error'] : fallback,\n ...(Array.isArray(body['issues']) ? { issues: body['issues'] as { path: (string | number)[]; message: string }[] } : {}),\n }\n}\n\n/**\n * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file\n * uploaded first, then the submission claiming them. The first failure ends it and is\n * returned with its stage and status; nothing is retried here.\n *\n * EC-685: every file's SHA-256 is computed FIRST, here — the one hashing site of both flows —\n * and declared with its `upload`. A browser computes it only on a secure page (WebCrypto,\n * https or localhost); elsewhere the post is refused before anything is sent (stage `upload`,\n * status 400, the reason in `error`), never sent without the digest.\n */\nexport async function sendFormSubmission(input: { target: string; payload: FormPayload; transport: SubmitTransport }): Promise<SubmitResult> {\n const { target, payload, transport } = input\n // A name the envelope (or the forms addon) would refuse is refused here, before anything is\n // sent — with the same reason, as the same `['data', name]` issues the route's 400 carries.\n const names = new Set([...Object.keys(payload.data), ...payload.files.map((file) => file.field)])\n const issues = [...names].flatMap((name) => {\n if (name.startsWith(FORMS_RESERVED_PREFIX)) return []\n const problem = formFieldNameProblem(name)\n return problem ? [{ path: ['data', name] as (string | number)[], message: formFieldNameMessage(name, problem) }] : []\n })\n if (issues.length > 0) {\n return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues }\n }\n const digests: string[] = []\n for (const { file } of payload.files) {\n const digest = await ingestUploadSha256(file)\n if (!digest.ok) return { ok: false, stage: 'upload', status: 400, error: digest.message }\n digests.push(digest.sha256)\n }\n const uploads: { uploadId: string; storageId: string }[] = []\n for (const [index, { field, file }] of payload.files.entries()) {\n // The type the browser reports; an unknown one is declared as an opaque binary.\n const contentType = file.type || 'application/octet-stream'\n const sha256 = digests[index]!\n const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size, sha256 })\n const issuedBody = await readJson(issued)\n if (!issued.ok) return failure('upload', issued.status, issuedBody, 'The upload was refused.')\n const uploadId = issuedBody['uploadId']\n const uploadUrl = issuedBody['uploadUrl']\n if (typeof uploadId !== 'string' || typeof uploadUrl !== 'string') {\n return failure('upload', 502, {}, 'The upload answer carried no upload URL.')\n }\n const stored = await transport.store(uploadUrl, file, contentType)\n const storedBody = await readJson(stored)\n if (!stored.ok || typeof storedBody['storageId'] !== 'string') {\n return failure('storage', stored.ok ? 502 : stored.status, storedBody, 'The file could not be stored.')\n }\n uploads.push({ uploadId, storageId: storedBody['storageId'] })\n }\n const sent = await transport.ingest({\n kind: 'submission',\n target,\n data: payload.data,\n ...(uploads.length > 0 ? { uploads } : {}),\n })\n const body = await readJson(sent)\n if (!sent.ok) return failure('submission', sent.status, body, 'The submission was refused.')\n return { ok: true, id: typeof body['id'] === 'string' ? body['id'] : '' }\n}\n\n/**\n * Submit a form from the BROWSER (a client component's submit handler): its fields and files\n * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the\n * deployment's storage — so an upload is not bound by the site's function body limit.\n *\n * ```tsx\n * const result = await submitForm({ form: event.currentTarget, target: 'contact' })\n * ```\n */\nexport function submitForm(input: {\n form: HTMLFormElement | FormData\n target: string\n /** The site's ingest route (default `/api/ingest`). */\n endpoint?: string\n honeypot?: string\n /** Default: the current page. */\n sourceUrl?: string\n fetchImpl?: typeof fetch\n}): Promise<SubmitResult> {\n const fetchImpl = input.fetchImpl ?? fetch\n const formData = input.form instanceof FormData ? input.form : new FormData(input.form)\n const sourceUrl = input.sourceUrl ?? (typeof location === 'undefined' ? undefined : location.href)\n const payload = formPayload(formData.entries() as Iterable<[string, PostedValue]>, {\n ...(input.honeypot ? { honeypot: input.honeypot } : {}),\n ...(sourceUrl ? { sourceUrl } : {}),\n })\n const endpoint = input.endpoint ?? '/api/ingest'\n return sendFormSubmission({\n target: input.target,\n payload,\n transport: {\n ingest: (body) =>\n fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),\n store: (uploadUrl, file, contentType) =>\n fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),\n },\n })\n}\n"]}
@@ -16,9 +16,11 @@ import type { FormValue, SpamVerdict, SubmissionField } from './kind.js';
16
16
  *
17
17
  * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
18
18
  * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
19
- * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
20
- * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
21
- * site helpers refuse a name before sending, with the same reason the envelope would give.
19
+ * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver
20
+ * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored
21
+ * payload's `name`). The site helpers refuse a name before sending, with the same reason the
22
+ * envelope would give. There is no definition-time gate: a form's fields are CMS content
23
+ * (`source.ts`), so a refused name surfaces on submission, on both site paths.
22
24
  */
23
25
  /** The reserved namespace of the forms wire. */
24
26
  export declare const FORMS_RESERVED_PREFIX = "elytra:";
@@ -16,9 +16,11 @@ import { formValueSchema } from './kind.js';
16
16
  *
17
17
  * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
18
18
  * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
19
- * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
20
- * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
21
- * site helpers refuse a name before sending, with the same reason the envelope would give.
19
+ * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver
20
+ * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored
21
+ * payload's `name`). The site helpers refuse a name before sending, with the same reason the
22
+ * envelope would give. There is no definition-time gate: a form's fields are CMS content
23
+ * (`source.ts`), so a refused name surfaces on submission, on both site paths.
22
24
  */
23
25
  /** The reserved namespace of the forms wire. */
24
26
  export const FORMS_RESERVED_PREFIX = 'elytra:';
@@ -1 +1 @@
1
- {"version":3,"file":"submission.js","sourceRoot":"","sources":["../../src/shared/submission.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAE/D,OAAO,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAA;AAGxC;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,gDAAgD;AAChD,MAAM,CAAC,MAAM,qBAAqB,GAAG,SAAS,CAAA;AAC9C,uCAAuC;AACvC,MAAM,CAAC,MAAM,gBAAgB,GAAG,eAAe,CAAA;AAC/C,4BAA4B;AAC5B,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAA;AACnD;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,aAAa,CAAA;AAE/C,qGAAqG;AACrG,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAA;AAEtC;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,MAAM,IAAI,GAAG,sBAAsB,CAAC,IAAI,CAAC,CAAA;IACzC,IAAI,IAAI;QAAE,OAAO,IAAI,CAAA;IACrB,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,UAAU,CAAA;IACxC,IAAI,IAAI,CAAC,MAAM,GAAG,mBAAmB;QAAE,OAAO,kBAAkB,mBAAmB,2CAA2C,CAAA;IAC9H,OAAO,SAAS,CAAA;AAClB,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,oBAAoB,CAAC,IAAY,EAAE,OAAe;IAChE,OAAO,mBAAmB,IAAI,KAAK,OAAO,GAAG,CAAA;AAC/C,CAAC;AACD,iGAAiG;AACjG,MAAM,CAAC,MAAM,sBAAsB,GAAG,SAAS,CAAA;AAE/C;;;;GAIG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAsB;IAC3D,iBAAiB;IACjB,oBAAoB;IACpB,yEAAyE;IACzE,yCAAyC;IACzC,iBAAiB;IACjB,YAAY;IACZ,UAAU;IACV,YAAY;IACZ,WAAW;IACX,YAAY;IACZ,WAAW;IACX,YAAY;IACZ,YAAY;CACb,CAAA;AAED,0CAA0C;AAC1C,MAAM,cAAc,GAAG,IAAI,CAAA;AAc3B,SAAS,YAAY,CAAC,KAAgB;IACpC,OAAO,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAA;AAC3G,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CACnC,OAAuD,EACvD,UAAkC,EAAE;IAEpC,MAAM,MAAM,GAAqD,EAAE,CAAA;IACnE,MAAM,MAAM,GAAsB,EAAE,CAAA;IACpC,IAAI,SAA6B,CAAA;IACjC,IAAI,IAAI,GAAgB,OAAO,CAAA;IAC/B,oFAAoF;IACpF,2FAA2F;IAC3F,6FAA6F;IAC7F,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACxG,IAAI,IAAI,KAAK,gBAAgB,EAAE,CAAC;YAC9B,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,MAAM,GAAG,cAAc,EAAE,CAAC;gBAC3D,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,IAAI,8BAA8B,cAAc,cAAc,EAAE,CAAC,CAAA;YACpH,CAAC;iBAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC;gBAAE,SAAS,GAAG,GAAG,CAAA;YAC1C,SAAQ;QACV,CAAC;QACD,IAAI,IAAI,KAAK,kBAAkB,EAAE,CAAC;YAChC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;gBAAE,IAAI,GAAG,UAAU,CAAA;YACvE,SAAQ;QACV,CAAC;QACD,IAAI,IAAI,CAAC,UAAU,CAAC,qBAAqB,CAAC,EAAE,CAAC;YAC3C,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,IAAI,kCAAkC,gBAAgB,KAAK,kBAAkB,IAAI,EAAE,CAAC,CAAA;YACrI,SAAQ;QACV,CAAC;QACD,MAAM,KAAK,GAAG,eAAe,CAAC,SAAS,CAAC,GAAG,CAAC,CAAA;QAC5C,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,IAAI,qEAAqE,EAAE,CAAC,CAAA;YAC7H,SAAQ;QACV,CAAC;QACD,MAAM,WAAW,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAA;QAC9C,IAAI,WAAW,EAAE,CAAC;YAChB,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,WAAW,CAAC,EAAE,CAAC,CAAA;YACvF,SAAQ;QACV,CAAC;QACD,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAA;IACzE,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QACpE,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,mCAAmC,EAAE,CAAC,CAAA;IAC/E,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,8BAA8B,EAAE,MAAM,EAAE,CAAA;IACpF,CAAC;IACD,OAAO;QACL,EAAE,EAAE,IAAI;QACR,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE;KAC9F,CAAA;AACH,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,eAAe,CAAC,KAAgB;IAC9C,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACjD,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAA;IAC3D,OAAO,MAAM,CAAC,KAAK,CAAC,CAAA;AACtB,CAAC","sourcesContent":["import { convexFieldNameProblem } from '@elytracms/core/ingest'\nimport type { IngestPayload } from '@elytracms/core/ingest'\nimport { formValueSchema } from './kind'\nimport type { FormValue, SpamVerdict, SubmissionField } from './kind'\n\n/**\n * EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`\n * payload carries for this addon, and the ONE reader the receiver's handler turns it into a\n * row with. Pure (no Convex), so the rules are unit-tested on their own.\n *\n * `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,\n * in the `elytra:` namespace:\n *\n * - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);\n * - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).\n *\n * Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are\n * not answers and are not stored; a submission with no answer and no file is refused.\n *\n * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name\n * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no\n * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with\n * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The\n * site helpers refuse a name before sending, with the same reason the envelope would give.\n */\n\n/** The reserved namespace of the forms wire. */\nexport const FORMS_RESERVED_PREFIX = 'elytra:'\n/** The page a submission came from. */\nexport const FORMS_SOURCE_KEY = 'elytra:source'\n/** The honeypot's value. */\nexport const FORMS_HONEYPOT_KEY = 'elytra:honeypot'\n/**\n * The hidden input a native (no-JS) form post names its form with — read by the `./next`\n * form-post route, never sent on: it becomes the ingest payload's `target`.\n */\nexport const FORMS_TARGET_FIELD = 'elytra:form'\n\n/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */\nexport const FORM_FIELD_NAME_MAX = 200\n\n/**\n * Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's\n * rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms\n * addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to\n * follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.\n */\nexport function formFieldNameProblem(name: string): string | undefined {\n const core = convexFieldNameProblem(name)\n if (core) return core\n if (name.length === 0) return 'is empty'\n if (name.length > FORM_FIELD_NAME_MAX) return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`\n return undefined\n}\n\n/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */\nexport function formFieldNameMessage(name: string, problem: string): string {\n return `The field name “${name}” ${problem}.`\n}\n/** The honeypot field both consumers' forms already render (overridable in the site helpers). */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/**\n * The file types a form upload may have — documents and raster images. Anything that a\n * browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is\n * refused by the receiver's handler (415), and so is every type not listed.\n */\nexport const FORMS_UPLOAD_CONTENT_TYPES: readonly string[] = [\n 'application/pdf',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.oasis.opendocument.text',\n 'application/rtf',\n 'text/plain',\n 'text/csv',\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/heic',\n 'image/heif',\n]\n\n/** The longest source URL a row keeps. */\nconst SOURCE_URL_MAX = 2000\n\n/** What a site's submission becomes in the row (the visitor's part — see the kind's header). */\nexport interface SubmissionColumns {\n form: string\n payload: SubmissionField[]\n sourceUrl?: string\n spam: SpamVerdict\n}\n\nexport type SubmissionRead =\n | { ok: true; columns: SubmissionColumns }\n | { ok: false; status: 422; message: string; issues: { path: (string | number)[]; message: string }[] }\n\nfunction isEmptyValue(value: FormValue): boolean {\n return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0)\n}\n\n/**\n * Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)\n * a value that is not a form value, an unknown reserved key, an over-long source URL, and a\n * submission with no answer — unless it carries files (`hasFiles`).\n */\nexport function readSubmissionPayload(\n payload: Extract<IngestPayload, { kind: 'submission' }>,\n options: { hasFiles?: boolean } = {},\n): SubmissionRead {\n const issues: { path: (string | number)[]; message: string }[] = []\n const fields: SubmissionField[] = []\n let sourceUrl: string | undefined\n let spam: SpamVerdict = 'clean'\n // Sorted by name: an object's key order does not survive a Convex function boundary\n // (measured — the receiver's handler receives the keys sorted), so the stored order is the\n // one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).\n for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {\n if (name === FORMS_SOURCE_KEY) {\n if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {\n issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` })\n } else if (raw.length > 0) sourceUrl = raw\n continue\n }\n if (name === FORMS_HONEYPOT_KEY) {\n if (typeof raw === 'string' && raw.trim().length > 0) spam = 'honeypot'\n continue\n }\n if (name.startsWith(FORMS_RESERVED_PREFIX)) {\n issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` })\n continue\n }\n const value = formValueSchema.safeParse(raw)\n if (!value.success) {\n issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` })\n continue\n }\n const nameProblem = formFieldNameProblem(name)\n if (nameProblem) {\n issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) })\n continue\n }\n if (!isEmptyValue(value.data)) fields.push({ name, value: value.data })\n }\n if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {\n issues.push({ path: ['data'], message: 'The submission carries no answer.' })\n }\n if (issues.length > 0) {\n return { ok: false, status: 422, message: 'The submission is not valid.', issues }\n }\n return {\n ok: true,\n columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },\n }\n}\n\n/** The display text of one value (a list joined by commas, a boolean as yes/no). */\nexport function formatFormValue(value: FormValue): string {\n if (Array.isArray(value)) return value.join(', ')\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n"]}
1
+ {"version":3,"file":"submission.js","sourceRoot":"","sources":["../../src/shared/submission.ts"],"names":[],"mappings":"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","sourcesContent":["import { convexFieldNameProblem } from '@elytracms/core/ingest'\nimport type { IngestPayload } from '@elytracms/core/ingest'\nimport { formValueSchema } from './kind'\nimport type { FormValue, SpamVerdict, SubmissionField } from './kind'\n\n/**\n * EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`\n * payload carries for this addon, and the ONE reader the receiver's handler turns it into a\n * row with. Pure (no Convex), so the rules are unit-tested on their own.\n *\n * `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,\n * in the `elytra:` namespace:\n *\n * - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);\n * - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).\n *\n * Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are\n * not answers and are not stored; a submission with no answer and no file is refused.\n *\n * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name\n * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no\n * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver\n * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored\n * payload's `name`). The site helpers refuse a name before sending, with the same reason the\n * envelope would give. There is no definition-time gate: a form's fields are CMS content\n * (`source.ts`), so a refused name surfaces on submission, on both site paths.\n */\n\n/** The reserved namespace of the forms wire. */\nexport const FORMS_RESERVED_PREFIX = 'elytra:'\n/** The page a submission came from. */\nexport const FORMS_SOURCE_KEY = 'elytra:source'\n/** The honeypot's value. */\nexport const FORMS_HONEYPOT_KEY = 'elytra:honeypot'\n/**\n * The hidden input a native (no-JS) form post names its form with — read by the `./next`\n * form-post route, never sent on: it becomes the ingest payload's `target`.\n */\nexport const FORMS_TARGET_FIELD = 'elytra:form'\n\n/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */\nexport const FORM_FIELD_NAME_MAX = 200\n\n/**\n * Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's\n * rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms\n * addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to\n * follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.\n */\nexport function formFieldNameProblem(name: string): string | undefined {\n const core = convexFieldNameProblem(name)\n if (core) return core\n if (name.length === 0) return 'is empty'\n if (name.length > FORM_FIELD_NAME_MAX) return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`\n return undefined\n}\n\n/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */\nexport function formFieldNameMessage(name: string, problem: string): string {\n return `The field name “${name}” ${problem}.`\n}\n/** The honeypot field both consumers' forms already render (overridable in the site helpers). */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/**\n * The file types a form upload may have — documents and raster images. Anything that a\n * browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is\n * refused by the receiver's handler (415), and so is every type not listed.\n */\nexport const FORMS_UPLOAD_CONTENT_TYPES: readonly string[] = [\n 'application/pdf',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.oasis.opendocument.text',\n 'application/rtf',\n 'text/plain',\n 'text/csv',\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/heic',\n 'image/heif',\n]\n\n/** The longest source URL a row keeps. */\nconst SOURCE_URL_MAX = 2000\n\n/** What a site's submission becomes in the row (the visitor's part — see the kind's header). */\nexport interface SubmissionColumns {\n form: string\n payload: SubmissionField[]\n sourceUrl?: string\n spam: SpamVerdict\n}\n\nexport type SubmissionRead =\n | { ok: true; columns: SubmissionColumns }\n | { ok: false; status: 422; message: string; issues: { path: (string | number)[]; message: string }[] }\n\nfunction isEmptyValue(value: FormValue): boolean {\n return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0)\n}\n\n/**\n * Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)\n * a value that is not a form value, an unknown reserved key, an over-long source URL, and a\n * submission with no answer — unless it carries files (`hasFiles`).\n */\nexport function readSubmissionPayload(\n payload: Extract<IngestPayload, { kind: 'submission' }>,\n options: { hasFiles?: boolean } = {},\n): SubmissionRead {\n const issues: { path: (string | number)[]; message: string }[] = []\n const fields: SubmissionField[] = []\n let sourceUrl: string | undefined\n let spam: SpamVerdict = 'clean'\n // Sorted by name: an object's key order does not survive a Convex function boundary\n // (measured — the receiver's handler receives the keys sorted), so the stored order is the\n // one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).\n for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {\n if (name === FORMS_SOURCE_KEY) {\n if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {\n issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` })\n } else if (raw.length > 0) sourceUrl = raw\n continue\n }\n if (name === FORMS_HONEYPOT_KEY) {\n if (typeof raw === 'string' && raw.trim().length > 0) spam = 'honeypot'\n continue\n }\n if (name.startsWith(FORMS_RESERVED_PREFIX)) {\n issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` })\n continue\n }\n const value = formValueSchema.safeParse(raw)\n if (!value.success) {\n issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` })\n continue\n }\n const nameProblem = formFieldNameProblem(name)\n if (nameProblem) {\n issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) })\n continue\n }\n if (!isEmptyValue(value.data)) fields.push({ name, value: value.data })\n }\n if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {\n issues.push({ path: ['data'], message: 'The submission carries no answer.' })\n }\n if (issues.length > 0) {\n return { ok: false, status: 422, message: 'The submission is not valid.', issues }\n }\n return {\n ok: true,\n columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },\n }\n}\n\n/** The display text of one value (a list joined by commas, a boolean as yes/no). */\nexport function formatFormValue(value: FormValue): string {\n if (Array.isArray(value)) return value.join(', ')\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n"]}
@@ -30,7 +30,7 @@ export interface FormsInboxConfig<V> {
30
30
  }
31
31
  /** The inbox's capabilities: no "New" (submissions come from the site), delete and bulk delete. */
32
32
  export declare const INBOX_CAPABILITIES: CollectionViewCapabilities;
33
- /** The rail-foot finding: how many submissions' mail did not (fully) go out. */
33
+ /** The issues-tray finding: how many submissions' mail did not (fully) go out. */
34
34
  declare function failedDeliveryIssues(count: number): NavIssue[];
35
35
  /** The inbox's deep link: `[]` the list, `[submissionId]` one submission. */
36
36
  declare function parseInboxPath(segments: string[]): Record<string, string | null> | null;
@@ -33,7 +33,13 @@ const DELIVERY_STATE_LABELS = {
33
33
  failed: { en: 'Failed', de: 'Fehlgeschlagen' },
34
34
  skipped: { en: 'Not sent', de: 'Nicht gesendet' },
35
35
  };
36
- /** The rail-foot finding: how many submissions' mail did not (fully) go out. */
36
+ /**
37
+ * EC-690 — where the finding is: the inbox, by the record kind it lists (the view declares
38
+ * `recordKind`), so the studio's issues tray offers "Go to" the inbox and shows the finding to
39
+ * whoever can open the inbox — not to every role.
40
+ */
41
+ const INBOX_LOCATION = { kind: 'record', recordKind: submissionRecordKind.kind };
42
+ /** The issues-tray finding: how many submissions' mail did not (fully) go out. */
37
43
  function failedDeliveryIssues(count) {
38
44
  return count === 0
39
45
  ? []
@@ -42,10 +48,11 @@ function failedDeliveryIssues(count) {
42
48
  key: 'forms-delivery-failed',
43
49
  message: `${count} form submission${count === 1 ? '' : 's'}: the mail was not delivered — open the inbox to retry.`,
44
50
  severity: 'error',
51
+ location: INBOX_LOCATION,
45
52
  },
46
53
  ];
47
54
  }
48
- /** The always-on rail-foot hook (it reads the inbox's own cached list). */
55
+ /** The always-on findings hook for the issues tray (it reads the inbox's own cached list). */
49
56
  function useInboxIssues({ projectId }) {
50
57
  const list = useRecordList(submissionRecordKind, projectId);
51
58
  const count = inboxIssueCount(list.data ?? []);
@@ -203,6 +210,8 @@ export function formsInbox(config) {
203
210
  icon: Inbox,
204
211
  parsePath: parseInboxPath,
205
212
  useIssues: useInboxIssues,
213
+ // EC-690: the record kind this view lists — a submission-located finding opens here.
214
+ recordKind: submissionRecordKind.kind,
206
215
  ...(config.visible ? { visible: config.visible } : {}),
207
216
  render: (ctx) => _jsx(FormsInbox, { ctx: ctx, config: config, id: id }),
208
217
  });
@@ -1 +1 @@
1
- {"version":3,"file":"inbox.js","sourceRoot":"","sources":["../../src/studio/inbox.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo, useState } from 'react'\nimport { Inbox } from 'lucide-react'\nimport {\n PortableDetailEditor,\n CollectionViewSurface,\n nav,\n normalizeOkResult,\n recordListKey,\n useRecordList,\n useRailViewSource,\n} from '@elytracms/studio/addon'\nimport type { AppViewContext, CollectionViewCapabilities, NavIssue, NavPermission, OkResult } from '@elytracms/studio/addon'\nimport { createCollectionRegistry, parseLocaleConfig, validateDocument } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CmsValidationIssue, DocumentRef, Locale, LocalizedString } from '@elytracms/core/cms-core'\nimport { Text } from '@elytracms/studio/ui'\nimport { DELIVERY_STATES, submissionRecordKind } from '../shared/kind'\nimport type { SubmissionRecord } from '../shared/kind'\nimport { deliveryNeedsAttention } from '../shared/delivery'\nimport { labelPayload } from '../shared/mail'\nimport type { FormDocument, FormSource } from '../shared/source'\nimport {\n formOf,\n inboxIssueCount,\n inboxSaveWrite,\n submissionLens,\n submissionSender,\n submissionToLensDocument,\n} from './lens'\nimport type { InboxForms } from './lens'\nimport { SubmissionAppendix } from './appendix'\n\n/**\n * EC-613 — the forms INBOX: the submission kind on the studio's portable list/edit surfaces\n * (AD-19, EC-616's scaffold for a source of one's own), one entry in the project's rail:\n *\n * ```tsx\n * // studio/studio.config.tsx\n * import { formsInbox } from '@elytracms/forms/studio'\n * import { contactForms } from '../cms/forms'\n * export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })\n * ```\n *\n * The list shows every submission, newest first; one opens in the portable edit shell, where\n * the editor sets the status, the assignee and notes (the inbox lifecycle) and reads — below\n * the fields — what was sent, the files and the mail delivery per recipient, with a retry for\n * a failed mail. A failed or partly failed delivery is an issue on the row AND at the rail\n * foot, from any surface. Reads and writes go through the operations client\n * (`operations.records(submissionRecordKind)`) and the one query cache (AD-17).\n */\n\nexport interface FormsInboxConfig<V> {\n /** How the inbox reads your form documents (labels, titles) — the same declaration the site's delivery route reads. */\n source: FormSource<V>\n /** The route segment (default `submissions`). */\n id?: string\n /** The rail label (default `Submissions`). */\n label?: string\n /** OPTIONAL, default-open nav permission (AD-18). */\n visible?: NavPermission\n}\n\n/** The inbox's capabilities: no \"New\" (submissions come from the site), delete and bulk delete. */\nexport const INBOX_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: false,\n duplicate: false,\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\nconst LENS_LOCALE: Locale = 'en'\nconst LENS_LOCALE_CONFIG = parseLocaleConfig({ default: LENS_LOCALE, locales: [LENS_LOCALE] })\n\nconst DELIVERY_STATE_LABELS: Record<(typeof DELIVERY_STATES)[number], LocalizedString> = {\n pending: { en: 'Sending', de: 'Wird gesendet' },\n sent: { en: 'Sent', de: 'Gesendet' },\n partial: { en: 'Partly failed', de: 'Teilweise fehlgeschlagen' },\n failed: { en: 'Failed', de: 'Fehlgeschlagen' },\n skipped: { en: 'Not sent', de: 'Nicht gesendet' },\n}\n\n/** The rail-foot finding: how many submissions' mail did not (fully) go out. */\nfunction failedDeliveryIssues(count: number): NavIssue[] {\n return count === 0\n ? []\n : [\n {\n key: 'forms-delivery-failed',\n message: `${count} form submission${count === 1 ? '' : 's'}: the mail was not delivered — open the inbox to retry.`,\n severity: 'error',\n },\n ]\n}\n\n/** The always-on rail-foot hook (it reads the inbox's own cached list). */\nfunction useInboxIssues({ projectId }: { projectId: string }): readonly NavIssue[] {\n const list = useRecordList(submissionRecordKind, projectId)\n const count = inboxIssueCount(list.data ?? [])\n return useMemo(() => failedDeliveryIssues(count), [count])\n}\n\n/** The form documents of the source's collection, through the operations client. */\nfunction useInboxForms<V>(ctx: AppViewContext, source: FormSource<V>): InboxForms<V> | undefined {\n const forms = ctx.query.useOperationQuery<FormDocument<V>[]>({\n queryKey: ['forms-inbox', 'forms', ctx.projectId, source.collection],\n loader: async (operations) => {\n const result = await operations.documents.list({ projectId: ctx.projectId, collection: source.collection })\n if (!result.ok) throw new Error(result.error.message)\n return result.value.map((record) => ({ id: record.document.id, values: record.document.values as V }))\n },\n })\n return useMemo(() => (forms.data ? { source, documents: forms.data } : undefined), [forms.data, source])\n}\n\n/** A failed delivery as a warning on the row's own Issues pane (never blocks a save). */\nfunction deliveryIssues(record: SubmissionRecord | undefined): CmsValidationIssue[] {\n if (!record || !deliveryNeedsAttention(record.deliveryState)) return []\n const failed = (record.deliveryLog ?? []).filter((entry) => !entry.ok)\n const last = failed.at(-1)\n return [\n {\n code: 'custom-validation',\n severity: 'warning',\n message: `The mail was not delivered${last?.to ? ` to ${last.to}` : ''}${last?.error ? `: ${last.error}` : ''}.`,\n path: ['deliveryState'],\n collectionId: submissionRecordKind.kind,\n documentId: record.id,\n },\n ]\n}\n\nfunction FormsInbox<V>({ ctx, config, id }: { ctx: AppViewContext; config: FormsInboxConfig<V>; id: string }) {\n const { projectId } = ctx\n const recordId = ctx.deepLink['submissionId'] ?? null\n const list = useRecordList(submissionRecordKind, projectId)\n const cache = ctx.query.useOperationCache()\n const forms = useInboxForms(ctx, config.source)\n const [retrying, setRetrying] = useState(false)\n const [retryError, setRetryError] = useState<string | null>(null)\n\n // Newest first — the inbox order.\n const records = useMemo(\n () => [...(list.data ?? [])].sort((a, b) => (a.submittedAt < b.submittedAt ? 1 : a.submittedAt > b.submittedAt ? -1 : 0)),\n [list.data],\n )\n const byId = useMemo(() => new Map(records.map((record) => [record.id, record])), [records])\n\n const schema = useMemo(() => submissionLens({ detail: recordId !== null }), [recordId])\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n\n const documents = useCallback(() => records.map((record) => submissionToLensDocument(record, forms)), [records, forms])\n const get = useCallback(\n (ref: DocumentRef) => {\n const record = byId.get(ref.id)\n return record ? submissionToLensDocument(record, forms) : undefined\n },\n [byId, forms],\n )\n const titleOf = useCallback(\n (ref: DocumentRef) => {\n const record = byId.get(ref.id)\n return record ? submissionSender(forms, record) || record.form : ref.id\n },\n [byId, forms],\n )\n const validate = useCallback(\n (doc: CmsDocument) => [\n ...validateDocument(registry, doc, { localeConfig: LENS_LOCALE_CONFIG }),\n ...deliveryIssues(byId.get(doc.id)),\n ],\n [registry, byId],\n )\n\n /** Write through the operations client and place the answer in the one cached list. */\n const write = useCallback(\n async (input: { id: string } & Record<string, unknown>): Promise<OkResult> => {\n const operations = await ctx.operations()\n const result = await operations.records(submissionRecordKind).save({ projectId, ...input } as never)\n if (!result.ok) return { ok: false, message: result.error.message }\n const saved = result.value as SubmissionRecord\n cache.patch<SubmissionRecord[]>(recordListKey(submissionRecordKind, projectId), (prev) => [\n ...(prev ?? []).filter((record) => record.id !== saved.id),\n saved,\n ])\n return { ok: true }\n },\n [ctx, projectId, cache],\n )\n\n const save = useCallback(\n async (doc: CmsDocument, base?: CmsDocument): Promise<OkResult> => {\n const stored = byId.get(doc.id)\n if (!stored) return { ok: false, message: `Submission “${doc.id}” is not loaded.` }\n // Against the record as the editor OPENED it (`base`, EC-616) — never the cache, which may\n // already hold another editor's change to a column this editor did not touch.\n const change = inboxSaveWrite(doc, base, stored, forms)\n // Nothing the editor owns changed: nothing to write (the displayed columns are never sent).\n if (Object.keys(change).length === 1) return { ok: true }\n return write(change)\n },\n [byId, write, forms],\n )\n const remove = useCallback(\n async (ref: DocumentRef): Promise<OkResult> => {\n const operations = await ctx.operations()\n const result = await operations.records(submissionRecordKind).remove({ projectId, id: ref.id })\n if (!result.ok) return { ok: false, message: result.error.message }\n cache.patch<SubmissionRecord[]>(recordListKey(submissionRecordKind, projectId), (prev) =>\n (prev ?? []).filter((record) => record.id !== ref.id),\n )\n return { ok: true }\n },\n [ctx, projectId, cache],\n )\n const create = useCallback((): DocumentRef => {\n throw new Error('Submissions come from the site — the inbox creates none.')\n }, [])\n\n const source = useRailViewSource({\n projectId,\n schema,\n capabilities: INBOX_CAPABILITIES,\n localeConfig: LENS_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n detailPath: (submissionId: string) => `${id}/${submissionId}`,\n listPath: id,\n validate,\n create,\n save,\n remove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeOkResult,\n })\n\n const retry = useCallback(async () => {\n if (!recordId) return\n setRetrying(true)\n setRetryError(null)\n // Moving the row INTO `pending` is the retry: the deployment schedules the delivery.\n const result = await write({ id: recordId, deliveryState: 'pending' })\n if (!result.ok) setRetryError(result.message)\n setRetrying(false)\n }, [recordId, write])\n\n if (list.status === 'pending') return <InboxMessage text=\"Loading submissions…\" />\n if (list.status === 'error') return <InboxMessage text={`Submissions could not be loaded: ${list.error?.message ?? 'unknown error'}`} />\n if (recordId === null) return <CollectionViewSurface source={source} projectId={projectId} />\n\n const record = byId.get(recordId)\n const form = record ? formOf(forms, record) : undefined\n const fields = record ? labelPayload(record.payload, form ? (config.source.fields?.(form.values) ?? []) : []) : []\n return (\n <PortableDetailEditor\n source={source}\n projectId={projectId}\n documentId={recordId}\n formAppendix={\n record ? (\n <>\n <SubmissionAppendix\n record={record}\n fields={fields}\n stateLabel={DELIVERY_STATE_LABELS[record.deliveryState]}\n onRetry={() => void retry()}\n retrying={retrying}\n />\n {retryError ? <InboxMessage text={retryError} /> : null}\n </>\n ) : null\n }\n />\n )\n}\n\nfunction InboxMessage({ text }: { text: string }) {\n return (\n <Text tone=\"muted\" className=\"p-6\" data-testid=\"forms-inbox-message\">\n {text}\n </Text>\n )\n}\n\n/** The inbox's deep link: `[]` the list, `[submissionId]` one submission. */\nfunction parseInboxPath(segments: string[]): Record<string, string | null> | null {\n if (segments.length === 0) return {}\n if (segments.length === 1) return { submissionId: decodeURIComponent(segments[0]!) }\n return null\n}\n\n/** The forms inbox as a rail entry (see the module header). */\nexport function formsInbox<V>(config: FormsInboxConfig<V>) {\n const id = config.id ?? 'submissions'\n return nav.view({\n id,\n label: config.label ?? 'Submissions',\n description: 'Form submissions from the site, with their mail delivery.',\n icon: Inbox,\n parsePath: parseInboxPath,\n useIssues: useInboxIssues,\n ...(config.visible ? { visible: config.visible } : {}),\n render: (ctx) => <FormsInbox ctx={ctx} config={config} id={id} />,\n })\n}\n\nexport { failedDeliveryIssues, parseInboxPath }\n"]}
1
+ {"version":3,"file":"inbox.js","sourceRoot":"","sources":["../../src/studio/inbox.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACtD,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AACpC,OAAO,EACL,oBAAoB,EACpB,qBAAqB,EACrB,GAAG,EACH,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,iBAAiB,GAClB,MAAM,yBAAyB,CAAA;AAShC,OAAO,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAExG,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,OAAO,EACL,MAAM,EACN,eAAe,EACf,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,QAAQ,CAAA;AAEf,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAgC/C,mGAAmG;AACnG,MAAM,CAAC,MAAM,kBAAkB,GAA+B;IAC5D,OAAO,EAAE,KAAK;IACd,QAAQ,EAAE,KAAK;IACf,OAAO,EAAE,KAAK;IACd,UAAU,EAAE,KAAK;IACjB,MAAM,EAAE,KAAK;IACb,SAAS,EAAE,KAAK;IAChB,MAAM,EAAE,IAAI;IACZ,IAAI,EAAE,IAAI;IACV,OAAO,EAAE,KAAK;IACd,SAAS,EAAE,KAAK;IAChB,YAAY,EAAE,KAAK;IACnB,SAAS,EAAE,KAAK;CACjB,CAAA;AAED,MAAM,WAAW,GAAW,IAAI,CAAA;AAChC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,WAAW,CAAC,EAAE,CAAC,CAAA;AAE9F,MAAM,qBAAqB,GAA8D;IACvF,OAAO,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,eAAe,EAAE;IAC/C,IAAI,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,EAAE;IACpC,OAAO,EAAE,EAAE,EAAE,EAAE,eAAe,EAAE,EAAE,EAAE,0BAA0B,EAAE;IAChE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,gBAAgB,EAAE;IAC9C,OAAO,EAAE,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,gBAAgB,EAAE;CAClD,CAAA;AAED;;;;GAIG;AACH,MAAM,cAAc,GAAmB,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,oBAAoB,CAAC,IAAI,EAAE,CAAA;AAEhG,kFAAkF;AAClF,SAAS,oBAAoB,CAAC,KAAa;IACzC,OAAO,KAAK,KAAK,CAAC;QAChB,CAAC,CAAC,EAAE;QACJ,CAAC,CAAC;YACE;gBACE,GAAG,EAAE,uBAAuB;gBAC5B,OAAO,EAAE,GAAG,KAAK,mBAAmB,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,yDAAyD;gBACnH,QAAQ,EAAE,OAAO;gBACjB,QAAQ,EAAE,cAAc;aACzB;SACF,CAAA;AACP,CAAC;AAED,8FAA8F;AAC9F,SAAS,cAAc,CAAC,EAAE,SAAS,EAAyB;IAC1D,MAAM,IAAI,GAAG,aAAa,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAA;IAC3D,MAAM,KAAK,GAAG,eAAe,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAC9C,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC,oBAAoB,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;AAC5D,CAAC;AAED,oFAAoF;AACpF,SAAS,aAAa,CAAI,GAAmB,EAAE,MAAqB;IAClE,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,iBAAiB,CAAoB;QAC3D,QAAQ,EAAE,CAAC,aAAa,EAAE,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,UAAU,CAAC;QACpE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE;YAC3B,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC,CAAA;YAC3G,IAAI,CAAC,MAAM,CAAC,EAAE;gBAAE,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;YACrD,OAAO,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAW,EAAE,CAAC,CAAC,CAAA;QACxG,CAAC;KACF,CAAC,CAAA;IACF,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAA;AAC1G,CAAC;AAED,yFAAyF;AACzF,SAAS,cAAc,CAAC,MAAoC;IAC1D,IAAI,CAAC,MAAM,IAAI,CAAC,sBAAsB,CAAC,MAAM,CAAC,aAAa,CAAC;QAAE,OAAO,EAAE,CAAA;IACvE,MAAM,MAAM,GAAG,CAAC,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;IACtE,MAAM,IAAI,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IAC1B,OAAO;QACL;YACE,IAAI,EAAE,mBAAmB;YACzB,QAAQ,EAAE,SAAS;YACnB,OAAO,EAAE,6BAA6B,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG;YAChH,IAAI,EAAE,CAAC,eAAe,CAAC;YACvB,YAAY,EAAE,oBAAoB,CAAC,IAAI;YACvC,UAAU,EAAE,MAAM,CAAC,EAAE;SACtB;KACF,CAAA;AACH,CAAC;AAED,SAAS,UAAU,CAAI,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAoE;IAC1G,MAAM,EAAE,SAAS,EAAE,GAAG,GAAG,CAAA;IACzB,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,IAAI,CAAA;IACrD,MAAM,IAAI,GAAG,aAAa,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAA;IAC3D,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,iBAAiB,EAAE,CAAA;IAC3C,MAAM,KAAK,GAAG,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;IAC/C,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAC/C,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IAEjE,kCAAkC;IAClC,MAAM,OAAO,GAAG,OAAO,CACrB,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EACzH,CAAC,IAAI,CAAC,IAAI,CAAC,CACZ,CAAA;IACD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAA;IAE5F,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAA;IACvF,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,wBAAwB,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAE5E,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,wBAAwB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;IACvH,MAAM,GAAG,GAAG,WAAW,CACrB,CAAC,GAAgB,EAAE,EAAE;QACnB,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAC/B,OAAO,MAAM,CAAC,CAAC,CAAC,wBAAwB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACrE,CAAC,EACD,CAAC,IAAI,EAAE,KAAK,CAAC,CACd,CAAA;IACD,MAAM,OAAO,GAAG,WAAW,CACzB,CAAC,GAAgB,EAAE,EAAE;QACnB,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAC/B,OAAO,MAAM,CAAC,CAAC,CAAC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAA;IACzE,CAAC,EACD,CAAC,IAAI,EAAE,KAAK,CAAC,CACd,CAAA;IACD,MAAM,QAAQ,GAAG,WAAW,CAC1B,CAAC,GAAgB,EAAE,EAAE,CAAC;QACpB,GAAG,gBAAgB,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE,YAAY,EAAE,kBAAkB,EAAE,CAAC;QACxE,GAAG,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;KACpC,EACD,CAAC,QAAQ,EAAE,IAAI,CAAC,CACjB,CAAA;IAED,uFAAuF;IACvF,MAAM,KAAK,GAAG,WAAW,CACvB,KAAK,EAAE,KAA+C,EAAqB,EAAE;QAC3E,MAAM,UAAU,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAA;QACzC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAW,CAAC,CAAA;QACpG,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,CAAA;QACnE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAyB,CAAA;QAC9C,KAAK,CAAC,KAAK,CAAqB,aAAa,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC;YACxF,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,CAAC;YAC1D,KAAK;SACN,CAAC,CAAA;QACF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;IACrB,CAAC,EACD,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CACxB,CAAA;IAED,MAAM,IAAI,GAAG,WAAW,CACtB,KAAK,EAAE,GAAgB,EAAE,IAAkB,EAAqB,EAAE;QAChE,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAC/B,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe,GAAG,CAAC,EAAE,kBAAkB,EAAE,CAAA;QACnF,2FAA2F;QAC3F,8EAA8E;QAC9E,MAAM,MAAM,GAAG,cAAc,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,CAAA;QACvD,4FAA4F;QAC5F,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;QACzD,OAAO,KAAK,CAAC,MAAM,CAAC,CAAA;IACtB,CAAC,EACD,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CACrB,CAAA;IACD,MAAM,MAAM,GAAG,WAAW,CACxB,KAAK,EAAE,GAAgB,EAAqB,EAAE;QAC5C,MAAM,UAAU,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAA;QACzC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAA;QAC/F,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,CAAA;QACnE,KAAK,CAAC,KAAK,CAAqB,aAAa,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CACvF,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CACtD,CAAA;QACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;IACrB,CAAC,EACD,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CACxB,CAAA;IACD,MAAM,MAAM,GAAG,WAAW,CAAC,GAAgB,EAAE;QAC3C,MAAM,IAAI,KAAK,CAAC,0DAA0D,CAAC,CAAA;IAC7E,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,MAAM,MAAM,GAAG,iBAAiB,CAAC;QAC/B,SAAS;QACT,MAAM;QACN,YAAY,EAAE,kBAAkB;QAChC,YAAY,EAAE,kBAAkB;QAChC,SAAS;QACT,GAAG;QACH,OAAO;QACP,UAAU,EAAE,CAAC,YAAoB,EAAE,EAAE,CAAC,GAAG,EAAE,IAAI,YAAY,EAAE;QAC7D,QAAQ,EAAE,EAAE;QACZ,QAAQ;QACR,MAAM;QACN,IAAI;QACJ,MAAM;QACN,mBAAmB,EAAE,iBAAiB;QACtC,qBAAqB,EAAE,iBAAiB;KACzC,CAAC,CAAA;IAEF,MAAM,KAAK,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QACnC,IAAI,CAAC,QAAQ;YAAE,OAAM;QACrB,WAAW,CAAC,IAAI,CAAC,CAAA;QACjB,aAAa,CAAC,IAAI,CAAC,CAAA;QACnB,qFAAqF;QACrF,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,aAAa,EAAE,SAAS,EAAE,CAAC,CAAA;QACtE,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,aAAa,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QAC7C,WAAW,CAAC,KAAK,CAAC,CAAA;IACpB,CAAC,EAAE,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAA;IAErB,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;QAAE,OAAO,KAAC,YAAY,IAAC,IAAI,EAAC,2BAAsB,GAAG,CAAA;IAClF,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO;QAAE,OAAO,KAAC,YAAY,IAAC,IAAI,EAAE,oCAAoC,IAAI,CAAC,KAAK,EAAE,OAAO,IAAI,eAAe,EAAE,GAAI,CAAA;IACxI,IAAI,QAAQ,KAAK,IAAI;QAAE,OAAO,KAAC,qBAAqB,IAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,GAAI,CAAA;IAE7F,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACjC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACvD,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IAClH,OAAO,CACL,KAAC,oBAAoB,IACnB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,SAAS,EACpB,UAAU,EAAE,QAAQ,EACpB,YAAY,EACV,MAAM,CAAC,CAAC,CAAC,CACP,8BACE,KAAC,kBAAkB,IACjB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,qBAAqB,CAAC,MAAM,CAAC,aAAa,CAAC,EACvD,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,KAAK,EAAE,EAC3B,QAAQ,EAAE,QAAQ,GAClB,EACD,UAAU,CAAC,CAAC,CAAC,KAAC,YAAY,IAAC,IAAI,EAAE,UAAU,GAAI,CAAC,CAAC,CAAC,IAAI,IACtD,CACJ,CAAC,CAAC,CAAC,IAAI,GAEV,CACH,CAAA;AACH,CAAC;AAED,SAAS,YAAY,CAAC,EAAE,IAAI,EAAoB;IAC9C,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,OAAO,EAAC,SAAS,EAAC,KAAK,iBAAa,qBAAqB,YACjE,IAAI,GACA,CACR,CAAA;AACH,CAAC;AAED,6EAA6E;AAC7E,SAAS,cAAc,CAAC,QAAkB;IACxC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IACpC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,YAAY,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAE,CAAC,EAAE,CAAA;IACpF,OAAO,IAAI,CAAA;AACb,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,UAAU,CAAI,MAA2B;IACvD,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,IAAI,aAAa,CAAA;IACrC,OAAO,GAAG,CAAC,IAAI,CAAC;QACd,EAAE;QACF,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,aAAa;QACpC,WAAW,EAAE,2DAA2D;QACxE,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,cAAc;QACzB,SAAS,EAAE,cAAc;QACzB,qFAAqF;QACrF,UAAU,EAAE,oBAAoB,CAAC,IAAI;QACrC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtD,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAC,UAAU,IAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAI;KAClE,CAAC,CAAA;AACJ,CAAC;AAED,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,CAAA","sourcesContent":["import { useCallback, useMemo, useState } from 'react'\nimport { Inbox } from 'lucide-react'\nimport {\n PortableDetailEditor,\n CollectionViewSurface,\n nav,\n normalizeOkResult,\n recordListKey,\n useRecordList,\n useRailViewSource,\n} from '@elytracms/studio/addon'\nimport type {\n AppViewContext,\n CollectionViewCapabilities,\n NavIssue,\n NavPermission,\n NoticeLocation,\n OkResult,\n} from '@elytracms/studio/addon'\nimport { createCollectionRegistry, parseLocaleConfig, validateDocument } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CmsValidationIssue, DocumentRef, Locale, LocalizedString } from '@elytracms/core/cms-core'\nimport { Text } from '@elytracms/studio/ui'\nimport { DELIVERY_STATES, submissionRecordKind } from '../shared/kind'\nimport type { SubmissionRecord } from '../shared/kind'\nimport { deliveryNeedsAttention } from '../shared/delivery'\nimport { labelPayload } from '../shared/mail'\nimport type { FormDocument, FormSource } from '../shared/source'\nimport {\n formOf,\n inboxIssueCount,\n inboxSaveWrite,\n submissionLens,\n submissionSender,\n submissionToLensDocument,\n} from './lens'\nimport type { InboxForms } from './lens'\nimport { SubmissionAppendix } from './appendix'\n\n/**\n * EC-613 — the forms INBOX: the submission kind on the studio's portable list/edit surfaces\n * (AD-19, EC-616's scaffold for a source of one's own), one entry in the project's rail:\n *\n * ```tsx\n * // studio/studio.config.tsx\n * import { formsInbox } from '@elytracms/forms/studio'\n * import { contactForms } from '../cms/forms'\n * export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })\n * ```\n *\n * The list shows every submission, newest first; one opens in the portable edit shell, where\n * the editor sets the status, the assignee and notes (the inbox lifecycle) and reads — below\n * the fields — what was sent, the files and the mail delivery per recipient, with a retry for\n * a failed mail. A failed or partly failed delivery is an issue on the row AND at the rail\n * foot, from any surface. Reads and writes go through the operations client\n * (`operations.records(submissionRecordKind)`) and the one query cache (AD-17).\n */\n\nexport interface FormsInboxConfig<V> {\n /** How the inbox reads your form documents (labels, titles) — the same declaration the site's delivery route reads. */\n source: FormSource<V>\n /** The route segment (default `submissions`). */\n id?: string\n /** The rail label (default `Submissions`). */\n label?: string\n /** OPTIONAL, default-open nav permission (AD-18). */\n visible?: NavPermission\n}\n\n/** The inbox's capabilities: no \"New\" (submissions come from the site), delete and bulk delete. */\nexport const INBOX_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: false,\n duplicate: false,\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\nconst LENS_LOCALE: Locale = 'en'\nconst LENS_LOCALE_CONFIG = parseLocaleConfig({ default: LENS_LOCALE, locales: [LENS_LOCALE] })\n\nconst DELIVERY_STATE_LABELS: Record<(typeof DELIVERY_STATES)[number], LocalizedString> = {\n pending: { en: 'Sending', de: 'Wird gesendet' },\n sent: { en: 'Sent', de: 'Gesendet' },\n partial: { en: 'Partly failed', de: 'Teilweise fehlgeschlagen' },\n failed: { en: 'Failed', de: 'Fehlgeschlagen' },\n skipped: { en: 'Not sent', de: 'Nicht gesendet' },\n}\n\n/**\n * EC-690 — where the finding is: the inbox, by the record kind it lists (the view declares\n * `recordKind`), so the studio's issues tray offers \"Go to\" the inbox and shows the finding to\n * whoever can open the inbox — not to every role.\n */\nconst INBOX_LOCATION: NoticeLocation = { kind: 'record', recordKind: submissionRecordKind.kind }\n\n/** The issues-tray finding: how many submissions' mail did not (fully) go out. */\nfunction failedDeliveryIssues(count: number): NavIssue[] {\n return count === 0\n ? []\n : [\n {\n key: 'forms-delivery-failed',\n message: `${count} form submission${count === 1 ? '' : 's'}: the mail was not delivered — open the inbox to retry.`,\n severity: 'error',\n location: INBOX_LOCATION,\n },\n ]\n}\n\n/** The always-on findings hook for the issues tray (it reads the inbox's own cached list). */\nfunction useInboxIssues({ projectId }: { projectId: string }): readonly NavIssue[] {\n const list = useRecordList(submissionRecordKind, projectId)\n const count = inboxIssueCount(list.data ?? [])\n return useMemo(() => failedDeliveryIssues(count), [count])\n}\n\n/** The form documents of the source's collection, through the operations client. */\nfunction useInboxForms<V>(ctx: AppViewContext, source: FormSource<V>): InboxForms<V> | undefined {\n const forms = ctx.query.useOperationQuery<FormDocument<V>[]>({\n queryKey: ['forms-inbox', 'forms', ctx.projectId, source.collection],\n loader: async (operations) => {\n const result = await operations.documents.list({ projectId: ctx.projectId, collection: source.collection })\n if (!result.ok) throw new Error(result.error.message)\n return result.value.map((record) => ({ id: record.document.id, values: record.document.values as V }))\n },\n })\n return useMemo(() => (forms.data ? { source, documents: forms.data } : undefined), [forms.data, source])\n}\n\n/** A failed delivery as a warning on the row's own Issues pane (never blocks a save). */\nfunction deliveryIssues(record: SubmissionRecord | undefined): CmsValidationIssue[] {\n if (!record || !deliveryNeedsAttention(record.deliveryState)) return []\n const failed = (record.deliveryLog ?? []).filter((entry) => !entry.ok)\n const last = failed.at(-1)\n return [\n {\n code: 'custom-validation',\n severity: 'warning',\n message: `The mail was not delivered${last?.to ? ` to ${last.to}` : ''}${last?.error ? `: ${last.error}` : ''}.`,\n path: ['deliveryState'],\n collectionId: submissionRecordKind.kind,\n documentId: record.id,\n },\n ]\n}\n\nfunction FormsInbox<V>({ ctx, config, id }: { ctx: AppViewContext; config: FormsInboxConfig<V>; id: string }) {\n const { projectId } = ctx\n const recordId = ctx.deepLink['submissionId'] ?? null\n const list = useRecordList(submissionRecordKind, projectId)\n const cache = ctx.query.useOperationCache()\n const forms = useInboxForms(ctx, config.source)\n const [retrying, setRetrying] = useState(false)\n const [retryError, setRetryError] = useState<string | null>(null)\n\n // Newest first — the inbox order.\n const records = useMemo(\n () => [...(list.data ?? [])].sort((a, b) => (a.submittedAt < b.submittedAt ? 1 : a.submittedAt > b.submittedAt ? -1 : 0)),\n [list.data],\n )\n const byId = useMemo(() => new Map(records.map((record) => [record.id, record])), [records])\n\n const schema = useMemo(() => submissionLens({ detail: recordId !== null }), [recordId])\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n\n const documents = useCallback(() => records.map((record) => submissionToLensDocument(record, forms)), [records, forms])\n const get = useCallback(\n (ref: DocumentRef) => {\n const record = byId.get(ref.id)\n return record ? submissionToLensDocument(record, forms) : undefined\n },\n [byId, forms],\n )\n const titleOf = useCallback(\n (ref: DocumentRef) => {\n const record = byId.get(ref.id)\n return record ? submissionSender(forms, record) || record.form : ref.id\n },\n [byId, forms],\n )\n const validate = useCallback(\n (doc: CmsDocument) => [\n ...validateDocument(registry, doc, { localeConfig: LENS_LOCALE_CONFIG }),\n ...deliveryIssues(byId.get(doc.id)),\n ],\n [registry, byId],\n )\n\n /** Write through the operations client and place the answer in the one cached list. */\n const write = useCallback(\n async (input: { id: string } & Record<string, unknown>): Promise<OkResult> => {\n const operations = await ctx.operations()\n const result = await operations.records(submissionRecordKind).save({ projectId, ...input } as never)\n if (!result.ok) return { ok: false, message: result.error.message }\n const saved = result.value as SubmissionRecord\n cache.patch<SubmissionRecord[]>(recordListKey(submissionRecordKind, projectId), (prev) => [\n ...(prev ?? []).filter((record) => record.id !== saved.id),\n saved,\n ])\n return { ok: true }\n },\n [ctx, projectId, cache],\n )\n\n const save = useCallback(\n async (doc: CmsDocument, base?: CmsDocument): Promise<OkResult> => {\n const stored = byId.get(doc.id)\n if (!stored) return { ok: false, message: `Submission “${doc.id}” is not loaded.` }\n // Against the record as the editor OPENED it (`base`, EC-616) — never the cache, which may\n // already hold another editor's change to a column this editor did not touch.\n const change = inboxSaveWrite(doc, base, stored, forms)\n // Nothing the editor owns changed: nothing to write (the displayed columns are never sent).\n if (Object.keys(change).length === 1) return { ok: true }\n return write(change)\n },\n [byId, write, forms],\n )\n const remove = useCallback(\n async (ref: DocumentRef): Promise<OkResult> => {\n const operations = await ctx.operations()\n const result = await operations.records(submissionRecordKind).remove({ projectId, id: ref.id })\n if (!result.ok) return { ok: false, message: result.error.message }\n cache.patch<SubmissionRecord[]>(recordListKey(submissionRecordKind, projectId), (prev) =>\n (prev ?? []).filter((record) => record.id !== ref.id),\n )\n return { ok: true }\n },\n [ctx, projectId, cache],\n )\n const create = useCallback((): DocumentRef => {\n throw new Error('Submissions come from the site — the inbox creates none.')\n }, [])\n\n const source = useRailViewSource({\n projectId,\n schema,\n capabilities: INBOX_CAPABILITIES,\n localeConfig: LENS_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n detailPath: (submissionId: string) => `${id}/${submissionId}`,\n listPath: id,\n validate,\n create,\n save,\n remove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeOkResult,\n })\n\n const retry = useCallback(async () => {\n if (!recordId) return\n setRetrying(true)\n setRetryError(null)\n // Moving the row INTO `pending` is the retry: the deployment schedules the delivery.\n const result = await write({ id: recordId, deliveryState: 'pending' })\n if (!result.ok) setRetryError(result.message)\n setRetrying(false)\n }, [recordId, write])\n\n if (list.status === 'pending') return <InboxMessage text=\"Loading submissions…\" />\n if (list.status === 'error') return <InboxMessage text={`Submissions could not be loaded: ${list.error?.message ?? 'unknown error'}`} />\n if (recordId === null) return <CollectionViewSurface source={source} projectId={projectId} />\n\n const record = byId.get(recordId)\n const form = record ? formOf(forms, record) : undefined\n const fields = record ? labelPayload(record.payload, form ? (config.source.fields?.(form.values) ?? []) : []) : []\n return (\n <PortableDetailEditor\n source={source}\n projectId={projectId}\n documentId={recordId}\n formAppendix={\n record ? (\n <>\n <SubmissionAppendix\n record={record}\n fields={fields}\n stateLabel={DELIVERY_STATE_LABELS[record.deliveryState]}\n onRetry={() => void retry()}\n retrying={retrying}\n />\n {retryError ? <InboxMessage text={retryError} /> : null}\n </>\n ) : null\n }\n />\n )\n}\n\nfunction InboxMessage({ text }: { text: string }) {\n return (\n <Text tone=\"muted\" className=\"p-6\" data-testid=\"forms-inbox-message\">\n {text}\n </Text>\n )\n}\n\n/** The inbox's deep link: `[]` the list, `[submissionId]` one submission. */\nfunction parseInboxPath(segments: string[]): Record<string, string | null> | null {\n if (segments.length === 0) return {}\n if (segments.length === 1) return { submissionId: decodeURIComponent(segments[0]!) }\n return null\n}\n\n/** The forms inbox as a rail entry (see the module header). */\nexport function formsInbox<V>(config: FormsInboxConfig<V>) {\n const id = config.id ?? 'submissions'\n return nav.view({\n id,\n label: config.label ?? 'Submissions',\n description: 'Form submissions from the site, with their mail delivery.',\n icon: Inbox,\n parsePath: parseInboxPath,\n useIssues: useInboxIssues,\n // EC-690: the record kind this view lists — a submission-located finding opens here.\n recordKind: submissionRecordKind.kind,\n ...(config.visible ? { visible: config.visible } : {}),\n render: (ctx) => <FormsInbox ctx={ctx} config={config} id={id} />,\n })\n}\n\nexport { failedDeliveryIssues, parseInboxPath }\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/forms",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.98",
4
+ "version": "0.0.100",
5
5
  "engines": {
6
6
  "node": ">=22.18.0"
7
7
  },
@@ -28,13 +28,13 @@
28
28
  "dependencies": {
29
29
  "lucide-react": "^1.23.0",
30
30
  "zod": "^4.4.3",
31
- "@elytracms/core": "^0.0.98"
31
+ "@elytracms/core": "^0.0.100"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "convex": "^1.42.1",
35
35
  "react": "^19.0.0",
36
- "@elytracms/next": "^0.0.98",
37
- "@elytracms/studio": "^0.0.98"
36
+ "@elytracms/next": "^0.0.100",
37
+ "@elytracms/studio": "^0.0.100"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@elytracms/next": {
@@ -59,8 +59,8 @@
59
59
  "react": "^19.2.7",
60
60
  "react-dom": "^19.2.7",
61
61
  "typescript": "^6.0.3",
62
- "@elytracms/next": "^0.0.98",
63
- "@elytracms/studio": "^0.0.98"
62
+ "@elytracms/next": "^0.0.100",
63
+ "@elytracms/studio": "^0.0.100"
64
64
  },
65
65
  "files": [
66
66
  "dist",
@@ -258,13 +258,16 @@ describe('EC-671 probe — a visitor names an asset’s storage id in an upload
258
258
  expect(await survives(t, own)).toBe(false)
259
259
  })
260
260
 
261
- it('control: a genuine mismatch is refused (no row); a genuine claim before it is released by the rollback', async () => {
261
+ it('control: a genuine mismatch is refused (no row); the genuine claim before it is never bound, so it survives (EC-695)', async () => {
262
+ // Before EC-695 the forms rollback released (deleted) this file — the shape (2a) and (3)
263
+ // guarded against. Now every claim is judged before any is bound: a refused submission binds
264
+ // nothing and nothing is released. The instrument's deletion control is the removal above.
262
265
  const t = await backend()
263
266
  const good = await declare(t, 'image/png', 500, 400)
264
267
  const goodFile = await storeFile(t, 400, 'image/png')
265
268
  const answer = await postWithRefusedSecondClaim(t, { uploadId: good, storageId: goodFile })
266
269
  expect(answer.status).toBe(415)
267
270
  expect(await rows(t)).toEqual([])
268
- expect(await survives(t, goodFile)).toBe(false)
271
+ expect(await survives(t, goodFile)).toBe(true)
269
272
  })
270
273
  })
@@ -145,7 +145,7 @@ async function leftovers(t: Backend) {
145
145
  describe('pnpm dev:ingest — every scenario against the real engine (convex-test)', () => {
146
146
  it('each scenario’s expectations hold; nothing printed holds the secret; no runner row is left', async () => {
147
147
  const core = await runner()
148
- expect(core.INGEST_SCENARIOS.map((scenario) => scenario.name)).toEqual(['ec671', 'ec685-causal', 'ec685-release', 'storage-sha256', 'ec618-burst'])
148
+ expect(core.INGEST_SCENARIOS.map((scenario) => scenario.name)).toEqual(['ec671', 'ec685-causal', 'ec685-release', 'storage-sha256', 'ec618-burst', 'ec695'])
149
149
  for (const scenario of core.INGEST_SCENARIOS) {
150
150
  const t = await backend()
151
151
  const lines: string[] = []
@@ -173,6 +173,28 @@ describe('pnpm dev:ingest — every scenario against the real engine (convex-tes
173
173
  expect(lines).toContain(' · _storage.sha256 length: 44')
174
174
  })
175
175
 
176
+ it('the EC-695 scenario: 23/23 — the substitution binds and deletes nothing, B binds after, the repeat is refused (the close’s expected lines)', async () => {
177
+ const core = await runner()
178
+ const t = await backend()
179
+ const lines: string[] = []
180
+ const scenario = core.INGEST_SCENARIOS.find((entry) => entry.name === 'ec695')!
181
+ const result = await core.runIngestScenario(scenario, port(core, t), { say: (line) => lines.push(line), wire, runId: 'r', now: () => T0 })
182
+ expect(result).toEqual({ ok: true, passed: 23, failed: [] })
183
+ expect(lines).toEqual(
184
+ expect.arrayContaining([
185
+ ' ✓ (a) A’s submission (first claim: B’s file; second: a mismatch) is refused: 422',
186
+ ' ✓ (a) …it deletes nothing: B’s file is still stored: true',
187
+ ' ✓ (a) A’s first intent was never spent: issued',
188
+ ' ✓ (a) B’s claim then binds B’s file: 200',
189
+ ' ✓ (b) a submission claiming one upload twice is refused before it is sent: refused',
190
+ '✓ ec695: 23/23 expectation(s) held',
191
+ ]),
192
+ )
193
+ expect(await leftovers(t)).toEqual({ submissions: 0, bindings: 0, assets: 0 })
194
+ // Every stored byte the scenario made is gone (bound files with their rows, the orphan with its asset).
195
+ expect(await t.run(async (ctx) => (await ctx.db.system.query('_storage').collect()).length)).toBe(0)
196
+ })
197
+
176
198
  it('negative control: signed under ANOTHER secret, every scenario fails visibly (the receiver refuses it)', async () => {
177
199
  const core = await runner()
178
200
  for (const scenario of core.INGEST_SCENARIOS) {
@@ -4,8 +4,15 @@ import type { ConvexHttpClient } from 'convex/browser'
4
4
  import { makeFunctionReference } from 'convex/server'
5
5
  import type { FunctionReference } from 'convex/server'
6
6
  import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
7
- import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
7
+ import {
8
+ INGEST_RECEIVER_PATH,
9
+ INGEST_SIGNATURE_HEADER,
10
+ convexFieldNameProblem,
11
+ ingestUploadSha256,
12
+ signedIngestRequest,
13
+ } from '@elytracms/core/ingest'
8
14
  import type { IngestReceiverBody } from '@elytracms/core/ingest'
15
+ import { signRevalidationBody } from '@elytracms/core/revalidation'
9
16
  import { InMemoryRecordStore } from '@elytracms/core/persistence'
10
17
  import type { RecordRepository } from '@elytracms/core/persistence'
11
18
  import schema from '../../../studio/convex/schema'
@@ -409,7 +416,7 @@ describe('uploads, claimed by the submission', () => {
409
416
  expect(await t.run((ctx) => ctx.storage.get(storageId as never))).toBeNull()
410
417
  })
411
418
 
412
- it('a claim that fails refuses the submission AND deletes the files it had already claimed (never the refused one)', async () => {
419
+ it('a claim that fails refuses the submission and binds NOTHING: the passing claim’s file is never bound, so nothing is deleted (EC-695)', async () => {
413
420
  const t = await backend()
414
421
  site()
415
422
  const good = await declareUpload(t, 'image/png', 500, 'photo', 400)
@@ -425,10 +432,15 @@ describe('uploads, claimed by the submission', () => {
425
432
  })
426
433
  expect(answer.status).toBe(415)
427
434
  expect(await rows(t)).toEqual([])
428
- expect(await t.run((ctx) => ctx.storage.get(goodFile as never))).toBeNull() // claimed, then released
435
+ // EC-695: every claim judged before any is bound — the passing one was never bound, so there is
436
+ // nothing to release (before: claimed, then released and deleted by the forms rollback).
437
+ expect(await t.run(async (ctx) => (await ctx.storage.get(goodFile as never)) !== null)).toBe(true)
438
+ expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'ingestBinding'))).toEqual([])
429
439
  // EC-671: the refused claim never deletes the id it names (the visitor's word, not an ownership fact).
430
440
  expect(await t.run(async (ctx) => (await ctx.storage.get(wrongType as never)) !== null)).toBe(true)
431
441
  expect(await scheduled(t)).toEqual([])
442
+ // The passing upload's intent was never spent: the corrected submission claims it.
443
+ expect((await post(t, { ...SUBMISSION, uploads: [{ uploadId: good.body.uploadId!, storageId: goodFile }] })).status).toBe(200)
432
444
  })
433
445
 
434
446
  it('the forms upload policy refuses what a browser would run (HTML, SVG) and anything over 10 MiB', async () => {
@@ -512,6 +524,72 @@ describe('the site’s form post, end to end', () => {
512
524
  expect(refused.headers.get('location')).toBe('https://site.test/?form=invalid')
513
525
  expect(await rows(t)).toHaveLength(1)
514
526
  })
527
+
528
+ /**
529
+ * EC-704 (Codex r6 F35) — the worst row, end to end: a field named `__proto__` beside an
530
+ * ordinary answer. Before, the builder's assignment lost it and the post landed a row WITHOUT
531
+ * the answer (`?form=ok`); a hand-written JSON body lost it in the envelope's record parse at
532
+ * the route and at the receiver alike. Now each of the three refuses it visibly, naming it, and
533
+ * nothing is stored.
534
+ */
535
+ it('EC-704: a field named `__proto__` → ?form=invalid, the route’s and the receiver’s 400 name it, NO row; control: the answer alone lands', async () => {
536
+ const t = await backend()
537
+ const viaReceiver = (async (url: string, init: RequestInit) => t.fetch(new URL(url).pathname, init)) as unknown as typeof fetch
538
+ const ingest = createIngestRoute({ secret: SECRET, siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
539
+ const formPost = createFormPostRoute({ ingest })
540
+ const post = (fields: [string, string][]) => {
541
+ const form = new FormData()
542
+ form.set(FORMS_TARGET_FIELD, 'contact')
543
+ form.set('website', '')
544
+ for (const [name, value] of fields) form.append(name, value)
545
+ return formPost.POST(
546
+ new Request('https://site.test/api/form', {
547
+ method: 'POST',
548
+ headers: { origin: 'https://site.test', referer: 'https://site.test/contact' },
549
+ body: form,
550
+ }),
551
+ )
552
+ }
553
+ const issue = { path: ['data', '__proto__'], message: `The field name “__proto__” ${convexFieldNameProblem('__proto__')}.` }
554
+
555
+ // 1. The no-JS form post (the browser helper builds through the same builder).
556
+ const refused = await post([['message', 'Hello'], ['__proto__', 'Keep this answer']])
557
+ expect(refused.headers.get('location')).toBe('https://site.test/contact?form=invalid')
558
+ expect(await rows(t)).toEqual([])
559
+
560
+ // 2. The site's route, a JSON body carrying the key as text (JSON.parse defines it).
561
+ const raw = '{"kind":"submission","target":"contact","data":{"message":"Hello","__proto__":"Keep this answer"}}'
562
+ const routed = await ingest.POST(
563
+ new Request('https://site.test/api/ingest', { method: 'POST', headers: { origin: 'https://site.test', 'content-type': 'application/json' }, body: raw }),
564
+ )
565
+ expect(routed.status).toBe(400)
566
+ expect((await routed.json()).issues).toEqual([issue])
567
+
568
+ // 3. The receiver, the same body SIGNED (a sender past the route).
569
+ const received = await t.fetch(INGEST_RECEIVER_PATH, {
570
+ method: 'POST',
571
+ headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(raw, SECRET) },
572
+ body: raw,
573
+ })
574
+ expect(received.status).toBe(400)
575
+ expect((await received.json()).issues).toEqual([issue])
576
+ expect(await rows(t)).toEqual([])
577
+
578
+ // Controls: the answer alone lands — through the post, and as the same signed body without the key.
579
+ const accepted = await post([['message', 'Hello']])
580
+ expect(accepted.headers.get('location')).toBe('https://site.test/contact?form=ok')
581
+ const clean = '{"kind":"submission","target":"contact","data":{"message":"Hello again"}}'
582
+ const direct = await t.fetch(INGEST_RECEIVER_PATH, {
583
+ method: 'POST',
584
+ headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(clean, SECRET) },
585
+ body: clean,
586
+ })
587
+ expect(direct.status).toBe(200)
588
+ expect((await rows(t)).map((row) => row.payload).sort((a, b) => String(a[0]!.value).localeCompare(String(b[0]!.value)))).toEqual([
589
+ [{ name: 'message', value: 'Hello' }],
590
+ [{ name: 'message', value: 'Hello again' }],
591
+ ])
592
+ })
515
593
  })
516
594
 
517
595
  // --- The kind's contract in both adapters --------------------------------------------------------
@@ -10,7 +10,7 @@ import { internalMutation, internalQuery, mutation } from '@elytracms/studio/con
10
10
  import type { MutationCtx } from '@elytracms/studio/convex/functions'
11
11
  import { requireCapability } from '@elytracms/studio/convex/guard'
12
12
  import {
13
- claimIngestUpload,
13
+ claimIngestUploads,
14
14
  ingestHandler,
15
15
  ingestHandlerArgs,
16
16
  ingestUploadRecordKind,
@@ -60,10 +60,13 @@ import type { FormDeliveryRequest } from '../shared/delivery'
60
60
  * - **`ingest`** — the receiver's `submission` handler (EC-617): an `upload` is accepted
61
61
  * under the forms upload policy and its intent recorded; a `submission` is read
62
62
  * (`readSubmissionPayload`), its uploads claimed, the row written and its delivery
63
- * scheduled. A claim that fails refuses the submission and deletes the files already
64
- * claimed for it — no claimed bytes ever sit without a row. Every deletion goes through
65
- * the engine's `releaseIngestUploads`, which deletes only bytes a claim bound to its
66
- * intent (EC-671): an id a visitor or an editor merely NAMES is never deleted;
63
+ * scheduled. The uploads are claimed in ONE engine call (`claimIngestUploads`, EC-695),
64
+ * which binds them all or none: a claim that fails refuses the submission with nothing
65
+ * bound, so no claimed bytes ever sit without a row and a refusal deletes nothing (a
66
+ * predictable file another intent stored can never be deleted through a rollback). The
67
+ * one deletion, `remove`, goes through the engine's `releaseIngestUploads`, which deletes
68
+ * only bytes a claim bound to its intent (EC-671): an id a visitor or an editor merely
69
+ * NAMES is never deleted;
67
70
  * - **`deliver`** — the scheduled action per row: it asks the site's delivery route to send
68
71
  * (`attemptFormDelivery`) and `recordDelivery` logs the answer per recipient and schedules
69
72
  * the next attempt (`applyDeliveryOutcome`);
@@ -175,16 +178,14 @@ export const ingest = internalMutation({
175
178
  const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
176
179
  if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
177
180
 
178
- // Claim every upload; on the first refusal, release what this submission already claimed
179
- // (bound in this transaction — EC-671: nothing else is ever deleted here).
181
+ // EC-695: every upload claimed in ONE engine call, after every check above that can refuse —
182
+ // all bound, or none (a refused claim refuses the submission, and nothing was bound, so
183
+ // nothing is released: this handler never deletes bytes). Nothing after it refuses.
184
+ const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt)
185
+ if (!claim.ok) return claim
180
186
  const files: SubmissionFile[] = []
181
187
  const namesUsed = new Map<string, number>()
182
- for (const claim of body.uploads ?? []) {
183
- const claimed = await claimIngestUpload(ctx, claim, body.target, receivedAt)
184
- if (!claimed.ok) {
185
- await releaseIngestUploads(ctx, storageIds(files))
186
- return claimed
187
- }
188
+ for (const claimed of claim.files) {
188
189
  const index = namesUsed.get(claimed.field) ?? 0
189
190
  namesUsed.set(claimed.field, index + 1)
190
191
  const name = fileNames(read.columns.payload, claimed.field)[index]
@@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { convexTest } from 'convex-test'
3
3
  import type { ConvexHttpClient } from 'convex/browser'
4
4
  import type { FunctionReference } from 'convex/server'
5
- import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
5
+ import { INGEST_RECEIVER_PATH, INGEST_SIGNATURE_HEADER, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
6
6
  import type { IngestReceiverBody } from '@elytracms/core/ingest'
7
+ import { signRevalidationBody } from '@elytracms/core/revalidation'
7
8
  import schema from '../../../studio/convex/schema'
8
9
  import * as assetsModule from '../../../studio/convex/assets'
9
10
  import * as deliveryModule from '../../../studio/convex/delivery'
@@ -173,6 +174,33 @@ function deleteAssetInStudio(t: Backend, assetId: string) {
173
174
 
174
175
  const assetIds = (t: Backend) => t.run(async (ctx) => (await ctx.db.query('assets').collect()).map((row) => row.assetId))
175
176
 
177
+ /** Every upload intent's state, by its id. */
178
+ const intentStates = (t: Backend) =>
179
+ t.run(async (ctx) =>
180
+ Object.fromEntries(
181
+ (await ctx.db.query('records').collect())
182
+ .filter((row) => row.kind === 'ingestUpload')
183
+ .map((row) => [row.recordId, (row.data as { state: string }).state]),
184
+ ),
185
+ )
186
+
187
+ /** One submission claiming every upload in `uploads`, in order. */
188
+ const claimAll = (t: Backend, uploads: { uploadId: string; storageId: string }[]) => post(t, { ...SUBMISSION, uploads })
189
+
190
+ /**
191
+ * The same, signed as RAW JSON with the deployment's secret — past the one builder
192
+ * (`signedIngestRequest` parses the body first, and refuses a repeated claim before signing:
193
+ * EC-695), so the receiver's own parse is what answers.
194
+ */
195
+ async function claimAllUnchecked(t: Backend, uploads: { uploadId: string; storageId: string }[]) {
196
+ const raw = JSON.stringify({ ...SUBMISSION, uploads })
197
+ return t.fetch(INGEST_RECEIVER_PATH, {
198
+ method: 'POST',
199
+ headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(raw, SECRET) },
200
+ body: raw,
201
+ })
202
+ }
203
+
176
204
  describe('EC-685 probe (1) — causation: a claim binds only bytes its own intent stored', () => {
177
205
  it('intents A and B issued; bytes stored through B’s URL: A’s claim naming them is refused, B’s claim binds them', async () => {
178
206
  const t = await backend()
@@ -202,6 +230,9 @@ describe('EC-685 probe (1) — causation: a claim binds only bytes its own inten
202
230
  it('RESIDUAL (named, not closed): bytes an attacker can PREDICT can be declared — A binds them first', async () => {
203
231
  // Causal only for unpredictable bytes: a deterministic file (one an addon generates, a
204
232
  // well-known document) is known before it exists, so its digest can be declared by anyone.
233
+ // EC-695 keeps it named and makes it NON-DESTRUCTIVE: B's claim is refused visibly (its
234
+ // intent marked rejected) and nothing is deleted. Option 1 (the engine receives the bytes,
235
+ // EC-304 Phase B) is what would close it.
205
236
  const t = await backend()
206
237
  const predictable = content('a template every visitor uploads', 800)
207
238
  const a = await declare(t, predictable, 'application/pdf')
@@ -210,6 +241,8 @@ describe('EC-685 probe (1) — causation: a claim binds only bytes its own inten
210
241
  expect((await claim(t, a, file)).status).toBe(200)
211
242
  expect((await claim(t, b, file)).status).toBe(422) // bound already (check 2)
212
243
  expect(await bindings(t)).toEqual([{ storageId: file, uploadId: a }])
244
+ expect(await intentStates(t)).toEqual({ [a]: 'claimed', [b]: 'rejected' }) // B's refusal is on record
245
+ expect(await survives(t, file)).toBe(true) // the substitution deletes nothing
213
246
  })
214
247
 
215
248
  it('control: a genuine claim binds (its own intent, its own bytes)', async () => {
@@ -263,3 +296,111 @@ describe('EC-685 probe (2) — ownership stays true when a delete spends it', ()
263
296
  expect(await survives(t, file)).toBe(false)
264
297
  })
265
298
  })
299
+
300
+ /**
301
+ * EC-695 (Codex round 5 F20 residual) — a predictable-bytes substitution must not be able to
302
+ * DELETE another intent's file. The engine judges every claim of a submission before it binds
303
+ * any (`claimIngestUploads`), so a refused submission binds nothing and the forms rollback's
304
+ * delete is gone; a duplicate in one submission is refused at the wire. Every defect line says
305
+ * what the base (`21678336`) did — its counterexample form passed 3/3 there before inversion.
306
+ */
307
+ describe('EC-695 probe — judge every claim, then bind: a substitution cannot delete another intent’s file', () => {
308
+ it('worst row: A’s first claim names B’s predictable file, its second mismatches — nothing is bound, nothing deleted; B’s claim binds', async () => {
309
+ const t = await backend()
310
+ const predictable = content('a template every visitor uploads', 800)
311
+ const a = await declare(t, predictable, 'application/pdf') // A declares the bytes it can predict…
312
+ const b = await declare(t, predictable, 'application/pdf') // …B, the victim, declares them too…
313
+ const file = await storeFile(t, predictable, 'application/pdf') // …and stores them through B's URL.
314
+ const a2 = await declare(t, content('what A’s second upload declares', 800), 'application/pdf')
315
+ const other = await storeFile(t, content('not what a2 declared', 800), 'application/pdf')
316
+ const answer = await claimAll(t, [
317
+ { uploadId: a, storageId: file },
318
+ { uploadId: a2, storageId: other }, // deliberately mismatching: the digest differs
319
+ ])
320
+ expect(answer.status).toBe(422)
321
+ expect(await rows(t)).toEqual([])
322
+ expect(await bindings(t)).toEqual([])
323
+ expect(await survives(t, file)).toBe(true) // base: false — the rollback released the file A's first claim had bound
324
+ expect(await survives(t, other)).toBe(true) // EC-671: a refused claim never deletes the id it names
325
+ // The claim that passed was never bound, so its intent was never spent; the refused one is on record.
326
+ expect(await intentStates(t)).toEqual({ [a]: 'issued', [b]: 'issued', [a2]: 'rejected' }) // base: a 'claimed'
327
+ const byB = await claim(t, b, file)
328
+ expect(byB.status).toBe(200) // base: 422 — "No stored file": the bytes were gone
329
+ expect(await bindings(t)).toEqual([{ storageId: file, uploadId: b }])
330
+ })
331
+
332
+ it('a refused submission leaves its passing claims claimable: the visitor’s corrected retry binds the same upload', async () => {
333
+ const t = await backend()
334
+ const cv = content('a genuine CV', 800)
335
+ const good = await declare(t, cv, 'application/pdf')
336
+ const goodFile = await storeFile(t, cv, 'application/pdf')
337
+ const liar = await declare(t, content('what the second upload declares', 800), 'application/pdf')
338
+ const wrong = await storeFile(t, content('a different file', 800), 'application/pdf')
339
+ expect((await claimAll(t, [{ uploadId: good, storageId: goodFile }, { uploadId: liar, storageId: wrong }])).status).toBe(422)
340
+ expect(await survives(t, goodFile)).toBe(true) // base: false — claimed, then released by the rollback
341
+ const retry = await claim(t, good, goodFile)
342
+ expect(retry.status).toBe(200) // base: 409 — the intent was spent by the claim the rollback undid
343
+ expect(await bindings(t)).toEqual([{ storageId: goodFile, uploadId: good }])
344
+ })
345
+
346
+ it('a duplicate uploadId in one submission is refused at the wire (400, naming it): nothing bound, the intent intact', async () => {
347
+ const t = await backend()
348
+ const bytes = content('a CV', 800)
349
+ const upload = await declare(t, bytes, 'application/pdf')
350
+ const file = await storeFile(t, bytes, 'application/pdf')
351
+ const twice = [
352
+ { uploadId: upload, storageId: file },
353
+ { uploadId: upload, storageId: file },
354
+ ]
355
+ const answer = await claimAllUnchecked(t, twice)
356
+ expect(answer.status).toBe(400) // base: 409 — the first claim bound, the second was "already claimed"
357
+ await expect(claimAll(t, twice)).rejects.toThrow() // …and the site's one builder refuses to sign it
358
+ expect(((await answer.json()) as { issues: { path: unknown[] }[] }).issues.map((issue) => issue.path)).toEqual([
359
+ ['uploads', 1, 'uploadId'],
360
+ ['uploads', 1, 'storageId'],
361
+ ])
362
+ expect(await survives(t, file)).toBe(true) // base: false — the rollback deleted it
363
+ expect(await intentStates(t)).toEqual({ [upload]: 'issued' })
364
+ expect((await claim(t, upload, file)).status).toBe(200) // control: the same claim, once, binds
365
+ })
366
+
367
+ it('a duplicate stored file across two intents in one submission is refused at the wire: nothing bound, neither intent spent', async () => {
368
+ const t = await backend()
369
+ const bytes = content('a CV', 800)
370
+ const first = await declare(t, bytes, 'application/pdf')
371
+ const second = await declare(t, bytes, 'application/pdf')
372
+ const file = await storeFile(t, bytes, 'application/pdf')
373
+ const answer = await claimAllUnchecked(t, [
374
+ { uploadId: first, storageId: file },
375
+ { uploadId: second, storageId: file },
376
+ ])
377
+ expect(answer.status).toBe(400) // base: 422 — the second failed check (2) against the first's binding
378
+ expect(((await answer.json()) as { issues: { path: unknown[] }[] }).issues.map((issue) => issue.path)).toEqual([
379
+ ['uploads', 1, 'storageId'],
380
+ ])
381
+ expect(await survives(t, file)).toBe(true) // base: false — the rollback deleted it
382
+ expect(await intentStates(t)).toEqual({ [first]: 'issued', [second]: 'issued' }) // base: claimed, rejected
383
+ expect(await bindings(t)).toEqual([])
384
+ })
385
+
386
+ it('control: a submission whose claims all pass binds every file, in order, on its row', async () => {
387
+ const t = await backend()
388
+ const one = content('the first file', 800)
389
+ const two = content('the second file', 600)
390
+ const u1 = await declare(t, one, 'application/pdf')
391
+ const f1 = await storeFile(t, one, 'application/pdf')
392
+ const u2 = await declare(t, two, 'application/pdf')
393
+ const f2 = await storeFile(t, two, 'application/pdf')
394
+ const answer = await claimAll(t, [
395
+ { uploadId: u1, storageId: f1 },
396
+ { uploadId: u2, storageId: f2 },
397
+ ])
398
+ expect(answer.status).toBe(200)
399
+ expect((await rows(t))[0]!.files!.map((file) => [file.storageId, file.size])).toEqual([
400
+ [f1, 800],
401
+ [f2, 600],
402
+ ])
403
+ expect((await bindings(t)).map((binding) => binding.storageId).sort()).toEqual([f1, f2].sort())
404
+ expect(await intentStates(t)).toEqual({ [u1]: 'claimed', [u2]: 'claimed' })
405
+ })
406
+ })
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { CONVEX_MAX_NESTING_DEPTH } from '@elytracms/core/cms-core'
3
+ import { convexFieldNameProblem } from '@elytracms/core/ingest'
3
4
  import { recordRowDepth, resolveRecordSave } from '@elytracms/core/persistence'
4
5
  import { submissionRecordKind } from './kind'
5
- import { FORMS_HONEYPOT_KEY, FORMS_SOURCE_KEY, readSubmissionPayload } from './submission'
6
+ import { FORMS_HONEYPOT_KEY, FORMS_SOURCE_KEY, formFieldNameProblem, readSubmissionPayload } from './submission'
6
7
 
7
8
  /** EC-613 — the one reader of a site's submission payload (the receiver's handler runs it). */
8
9
 
@@ -58,6 +59,21 @@ describe('readSubmissionPayload', () => {
58
59
  expect(readSubmissionPayload(submission({ name: '' })).ok).toBe(false)
59
60
  expect(readSubmissionPayload(submission({ name: '' }), { hasFiles: true }).ok).toBe(true)
60
61
  })
62
+
63
+ it('EC-704: a field named `__proto__` is refused by the ONE name rule (422 naming it); a VALUE "__proto__" is content', () => {
64
+ expect(formFieldNameProblem('__proto__')).toBe(convexFieldNameProblem('__proto__'))
65
+ expect(formFieldNameProblem('__proto__')).toBeDefined()
66
+ // An own key, as a parsed request body carries it (JSON.parse DEFINES it).
67
+ const read = readSubmissionPayload(submission(JSON.parse('{"message":"Hello","__proto__":"Keep this answer"}') as Record<string, unknown>))
68
+ expect(read).toMatchObject({ ok: false, status: 422, issues: [{ path: ['data', '__proto__'] }] })
69
+ // Controls: the answer alone, and the string '__proto__' as an answer, are read.
70
+ expect(readSubmissionPayload(submission({ message: 'Hello' })).ok).toBe(true)
71
+ const value = readSubmissionPayload(submission({ message: '__proto__', topics: ['__proto__'] }))
72
+ expect(value.ok && value.columns.payload).toEqual([
73
+ { name: 'message', value: '__proto__' },
74
+ { name: 'topics', value: ['__proto__'] },
75
+ ])
76
+ })
61
77
  })
62
78
 
63
79
  describe('the submission kind', () => {
@@ -19,9 +19,11 @@ import type { FormValue, SpamVerdict, SubmissionField } from './kind'
19
19
  *
20
20
  * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
21
21
  * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
22
- * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
23
- * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
24
- * site helpers refuse a name before sending, with the same reason the envelope would give.
22
+ * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver
23
+ * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored
24
+ * payload's `name`). The site helpers refuse a name before sending, with the same reason the
25
+ * envelope would give. There is no definition-time gate: a form's fields are CMS content
26
+ * (`source.ts`), so a refused name surfaces on submission, on both site paths.
25
27
  */
26
28
 
27
29
  /** The reserved namespace of the forms wire. */