@elytracms/forms 0.0.97 → 0.0.99

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.
@@ -3,7 +3,9 @@
3
3
  * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one
4
4
  * `submission` that claims the stored files. The ONE flow, with injected transports, so the
5
5
  * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send
6
- * exactly the same calls.
6
+ * exactly the same calls — and EC-685: hash every file in the same place, so each `upload`
7
+ * declares its bytes' SHA-256 (the claim binds a stored file only when its digest is the
8
+ * declared one).
7
9
  */
8
10
  /** A posted value: text, or a file (a `File` / `Blob` with a name). */
9
11
  type PostedValue = string | Blob;
@@ -55,6 +57,11 @@ export interface SubmitTransport {
55
57
  * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file
56
58
  * uploaded first, then the submission claiming them. The first failure ends it and is
57
59
  * returned with its stage and status; nothing is retried here.
60
+ *
61
+ * EC-685: every file's SHA-256 is computed FIRST, here — the one hashing site of both flows —
62
+ * and declared with its `upload`. A browser computes it only on a secure page (WebCrypto,
63
+ * https or localhost); elsewhere the post is refused before anything is sent (stage `upload`,
64
+ * status 400, the reason in `error`), never sent without the digest.
58
65
  */
59
66
  export declare function sendFormSubmission(input: {
60
67
  target: string;
@@ -1,3 +1,4 @@
1
+ import { ingestUploadSha256 } from '@elytracms/core/ingest';
1
2
  import { DEFAULT_HONEYPOT_FIELD, FORMS_HONEYPOT_KEY, FORMS_RESERVED_PREFIX, FORMS_SOURCE_KEY, FORMS_TARGET_FIELD, formFieldNameMessage, formFieldNameProblem, } from '../shared/submission.js';
2
3
  function isFile(value) {
3
4
  return typeof value !== 'string';
@@ -61,6 +62,11 @@ function failure(stage, status, body, fallback) {
61
62
  * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file
62
63
  * uploaded first, then the submission claiming them. The first failure ends it and is
63
64
  * returned with its stage and status; nothing is retried here.
65
+ *
66
+ * EC-685: every file's SHA-256 is computed FIRST, here — the one hashing site of both flows —
67
+ * and declared with its `upload`. A browser computes it only on a secure page (WebCrypto,
68
+ * https or localhost); elsewhere the post is refused before anything is sent (stage `upload`,
69
+ * status 400, the reason in `error`), never sent without the digest.
64
70
  */
65
71
  export async function sendFormSubmission(input) {
66
72
  const { target, payload, transport } = input;
@@ -76,11 +82,19 @@ export async function sendFormSubmission(input) {
76
82
  if (issues.length > 0) {
77
83
  return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues };
78
84
  }
85
+ const digests = [];
86
+ for (const { file } of payload.files) {
87
+ const digest = await ingestUploadSha256(file);
88
+ if (!digest.ok)
89
+ return { ok: false, stage: 'upload', status: 400, error: digest.message };
90
+ digests.push(digest.sha256);
91
+ }
79
92
  const uploads = [];
80
- for (const { field, file } of payload.files) {
93
+ for (const [index, { field, file }] of payload.files.entries()) {
81
94
  // The type the browser reports; an unknown one is declared as an opaque binary.
82
95
  const contentType = file.type || 'application/octet-stream';
83
- const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size });
96
+ const sha256 = digests[index];
97
+ const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size, sha256 });
84
98
  const issuedBody = await readJson(issued);
85
99
  if (!issued.ok)
86
100
  return failure('upload', issued.status, issuedBody, 'The upload was refused.');
@@ -1 +1 @@
1
- {"version":3,"file":"submit.js","sourceRoot":"","sources":["../../src/next/submit.ts"],"names":[],"mappings":"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","sourcesContent":["import {\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.\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 */\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 uploads: { uploadId: string; storageId: string }[] = []\n for (const { field, file } of payload.files) {\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 issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size })\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 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"]}
@@ -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":"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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 {\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.97",
4
+ "version": "0.0.99",
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.97"
31
+ "@elytracms/core": "^0.0.99"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "convex": "^1.42.1",
35
35
  "react": "^19.0.0",
36
- "@elytracms/next": "^0.0.97",
37
- "@elytracms/studio": "^0.0.97"
36
+ "@elytracms/next": "^0.0.99",
37
+ "@elytracms/studio": "^0.0.99"
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/studio": "^0.0.97",
63
- "@elytracms/next": "^0.0.97"
62
+ "@elytracms/studio": "^0.0.99",
63
+ "@elytracms/next": "^0.0.99"
64
64
  },
65
65
  "files": [
66
66
  "dist",
@@ -3,7 +3,7 @@ import { convexTest } from 'convex-test'
3
3
  import type { ConvexHttpClient } from 'convex/browser'
4
4
  import { makeFunctionReference } from 'convex/server'
5
5
  import type { FunctionReference } from 'convex/server'
6
- import { INGEST_RECEIVER_PATH, signedIngestRequest } from '@elytracms/core/ingest'
6
+ import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
7
7
  import type { IngestReceiverBody } from '@elytracms/core/ingest'
8
8
  import schema from '../../../studio/convex/schema'
9
9
  import * as assetsModule from '../../../studio/convex/assets'
@@ -120,8 +120,16 @@ async function harvest(t: Backend, assetId: string): Promise<string> {
120
120
  return asset.storageKey
121
121
  }
122
122
 
123
- async function declare(t: Backend, contentType: string, size: number, field = 'cv'): Promise<string> {
124
- const answer = await post(t, { kind: 'upload', target: 'contact', field, contentType, size })
123
+ /**
124
+ * EC-685: an intent declares its bytes' SHA-256 too. The harness stores zero-filled files, so
125
+ * `bytesOf` names the file whose digest an intent declares — for a "matching declaration" the
126
+ * VICTIM's (the strongest attacker: an asset's bytes are public, so its digest is known), which
127
+ * keeps each EC-671 check isolated from EC-685's.
128
+ */
129
+ async function declare(t: Backend, contentType: string, size: number, bytesOf = size, field = 'cv'): Promise<string> {
130
+ const digest = await ingestUploadSha256(new Uint8Array(bytesOf))
131
+ if (!digest.ok) throw new Error(digest.message)
132
+ const answer = await post(t, { kind: 'upload', target: 'contact', field, contentType, size, sha256: digest.sha256 })
125
133
  expect(answer.status).toBe(200)
126
134
  return ((await answer.json()) as { uploadId: string }).uploadId
127
135
  }
@@ -159,7 +167,7 @@ async function harvestedOldAsset(t: Backend): Promise<string> {
159
167
 
160
168
  /** A refused submission whose second claim is a genuine mismatch — the shape that triggers the rollback. */
161
169
  async function postWithRefusedSecondClaim(t: Backend, first: { uploadId: string; storageId: string }) {
162
- const liar = await declare(t, 'image/png', 500)
170
+ const liar = await declare(t, 'image/png', 500, 10) // its own 10 bytes' digest: the mismatch is the type
163
171
  const bad = await storeFile(t, 10, 'text/html')
164
172
  return post(t, { ...SUBMISSION, uploads: [first, { uploadId: liar, storageId: bad }] })
165
173
  }
@@ -237,7 +245,7 @@ describe('EC-671 probe — a visitor names an asset’s storage id in an upload
237
245
 
238
246
  it('control: a genuine upload claims and attaches; removing its submission deletes its file (the instrument sees a deletion)', async () => {
239
247
  const t = await backend()
240
- const uploadId = await declare(t, 'application/pdf', 900)
248
+ const uploadId = await declare(t, 'application/pdf', 900, 800)
241
249
  const own = await storeFile(t, 800, 'application/pdf')
242
250
  const answer = await post(t, { ...SUBMISSION, data: { ...SUBMISSION.data, cv: 'cv.pdf' }, uploads: [{ uploadId, storageId: own }] })
243
251
  expect(answer.status).toBe(200)
@@ -250,13 +258,16 @@ describe('EC-671 probe — a visitor names an asset’s storage id in an upload
250
258
  expect(await survives(t, own)).toBe(false)
251
259
  })
252
260
 
253
- 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.
254
265
  const t = await backend()
255
- const good = await declare(t, 'image/png', 500)
266
+ const good = await declare(t, 'image/png', 500, 400)
256
267
  const goodFile = await storeFile(t, 400, 'image/png')
257
268
  const answer = await postWithRefusedSecondClaim(t, { uploadId: good, storageId: goodFile })
258
269
  expect(answer.status).toBe(415)
259
270
  expect(await rows(t)).toEqual([])
260
- expect(await survives(t, goodFile)).toBe(false)
271
+ expect(await survives(t, goodFile)).toBe(true)
261
272
  })
262
273
  })
@@ -0,0 +1,214 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import * as wire from '@elytracms/core/ingest'
5
+ import schema from '../../../studio/convex/schema'
6
+ import * as assetsModule from '../../../studio/convex/assets'
7
+ import * as deliveryModule from '../../../studio/convex/delivery'
8
+ import { defineStudioHttp } from '../../../studio/convex/http'
9
+ import * as formsModule from './index'
10
+ // EC-618 (zone G): the `ec618-burst` scenario posts analytics events — the addon's handler answers them.
11
+ import * as analyticsModule from '../../../analytics/src/convex/index'
12
+ import { FORMS_DELIVERY_URL_ENV } from '../shared/delivery'
13
+
14
+ /**
15
+ * EC-685 — the dev ingest runner's scenarios (`scripts/dev-ingest-core.mjs`, `pnpm dev:ingest`),
16
+ * run END TO END against the real engine in convex-test before the operator runs them against
17
+ * the dev deployment: the port signs with a PLANTED secret that is this backend's
18
+ * `ELYTRA_INGEST_SECRET`, the receiver verifies it, the forms claim / removal and the studio's
19
+ * asset functions answer. Every scenario's expectations hold, nothing it printed holds the
20
+ * secret, and it leaves no runner row behind. Negative control: the same scenarios with a port
21
+ * signing under ANOTHER secret fail (the instrument can fail).
22
+ */
23
+
24
+ /** The runner's core, typed to what this test uses (a repo script, plain `.mjs`). */
25
+ interface RunnerPort {
26
+ ingest(payload: unknown): Promise<{ status: number; body: Record<string, unknown> }>
27
+ }
28
+ interface RunnerCore {
29
+ INGEST_SCENARIOS: readonly { name: string }[]
30
+ createDeploymentPort(options: Record<string, unknown>): RunnerPort
31
+ runIngestScenario(
32
+ scenario: { name: string },
33
+ port: RunnerPort,
34
+ options: { say: (line: string) => void; wire: typeof wire; runId: string; now: () => string },
35
+ ): Promise<{ ok: boolean; passed: number; failed: string[] }>
36
+ }
37
+ const RUNNER = '../../../../scripts/dev-ingest-core.mjs'
38
+ const runner = async () => (await import(RUNNER as string)) as RunnerCore
39
+
40
+ const PLANTED = 'planted-fake-ingest-secret-ec685'
41
+ const ADMIN = 'user_admin'
42
+ const T0 = '2026-10-28T12:00:00.000Z'
43
+ const SITE = 'https://rosy-armadillo-302.convex.site'
44
+
45
+ function env(): Record<string, string | undefined> {
46
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
47
+ }
48
+
49
+ beforeEach(() => {
50
+ vi.useFakeTimers()
51
+ vi.setSystemTime(new Date(T0))
52
+ env()['ELYTRA_INGEST_SECRET'] = PLANTED
53
+ env()[FORMS_DELIVERY_URL_ENV] = 'https://site.invalid/api/forms'
54
+ })
55
+ afterEach(() => {
56
+ vi.useRealTimers()
57
+ delete env()['ELYTRA_INGEST_SECRET']
58
+ delete env()[FORMS_DELIVERY_URL_ENV]
59
+ })
60
+
61
+ const silentAuth = { addHttpRoutes: () => {} }
62
+ const modules = {
63
+ '/convex/_generated/api.ts': async () => ({}),
64
+ '/convex/assets.ts': async () => assetsModule,
65
+ '/convex/delivery.ts': async () => deliveryModule,
66
+ '/convex/forms.ts': async () => formsModule,
67
+ '/convex/analytics.ts': async () => analyticsModule,
68
+ '/convex/http.ts': async () => ({
69
+ default: defineStudioHttp({ auth: silentAuth, ingest: { submission: formsModule.formsIngest, event: analyticsModule.analyticsIngest } }),
70
+ }),
71
+ }
72
+
73
+ async function backend() {
74
+ const t = convexTest(schema, modules)
75
+ await t.run(async (ctx) => {
76
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: T0, updatedAt: T0 })
77
+ })
78
+ return t
79
+ }
80
+ type Backend = Awaited<ReturnType<typeof backend>>
81
+ type Patchable = { patch(id: unknown, value: unknown): Promise<void> }
82
+
83
+ /**
84
+ * The deployment's HTTP surface over convex-test: the receiver (`t.fetch`), an issued upload URL
85
+ * (convex-test's URLs are not served — the bytes are stored as the upload would store them,
86
+ * with the uploader's Content-Type), and a serve URL (convex-test names a file by its digest).
87
+ */
88
+ function transport(t: Backend): typeof fetch {
89
+ return (async (input: string | URL, init: RequestInit = {}) => {
90
+ const url = new URL(String(input))
91
+ if (url.origin === SITE) return t.fetch(url.pathname, init)
92
+ if (url.pathname === '/api/storage/upload') {
93
+ const contentType = String(new Headers(init.headers).get('content-type'))
94
+ const bytes = init.body as Uint8Array<ArrayBuffer>
95
+ const storageId = await t.run(async (ctx) => {
96
+ const id = await ctx.storage.store(new Blob([bytes], { type: contentType }))
97
+ await (ctx.db as unknown as Patchable).patch(id, { contentType })
98
+ return id
99
+ })
100
+ return Response.json({ storageId })
101
+ }
102
+ if (url.pathname.startsWith('/api/storage/')) {
103
+ const sha256 = decodeURIComponent(url.pathname.slice('/api/storage/'.length))
104
+ const bytes = await t.run(async (ctx) => {
105
+ const row = (await ctx.db.system.query('_storage').collect()).find((file) => file.sha256 === sha256)
106
+ const blob = row ? await ctx.storage.get(row._id) : null
107
+ return blob ? Array.from(new Uint8Array(await blob.arrayBuffer())) : null
108
+ })
109
+ return bytes ? new Response(new Uint8Array(bytes)) : new Response('not found', { status: 404 })
110
+ }
111
+ return new Response('unroutable', { status: 599 })
112
+ }) as typeof fetch
113
+ }
114
+
115
+ function port(core: RunnerCore, t: Backend, secret = PLANTED) {
116
+ const asAdmin = t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
117
+ type Caller = (ref: unknown, args: object) => Promise<unknown>
118
+ const call = (kind: 'query' | 'mutation', on: { query: unknown; mutation?: unknown }) => (name: string, args: object) =>
119
+ (on[kind] as Caller)(makeFunctionReference(name as never), args)
120
+ return core.createDeploymentPort({
121
+ siteUrl: SITE,
122
+ secret,
123
+ fetchImpl: transport(t),
124
+ wire,
125
+ admin: { query: call('query', asAdmin), mutation: call('mutation', asAdmin) },
126
+ anon: { query: call('query', t) },
127
+ readTable: (table: string) =>
128
+ t.run(async (ctx) =>
129
+ table === '_storage'
130
+ ? ctx.db.system.query('_storage').order('desc').take(500)
131
+ : ctx.db.query(table as 'records').order('desc').take(500),
132
+ ),
133
+ })
134
+ }
135
+
136
+ /** What a scenario left behind: runner submissions, runner assets, stored files. */
137
+ async function leftovers(t: Backend) {
138
+ return t.run(async (ctx) => ({
139
+ submissions: (await ctx.db.query('records').collect()).filter((row) => row.kind === 'submission').length,
140
+ bindings: (await ctx.db.query('records').collect()).filter((row) => row.kind === 'ingestBinding').length,
141
+ assets: (await ctx.db.query('assets').collect()).length,
142
+ }))
143
+ }
144
+
145
+ describe('pnpm dev:ingest — every scenario against the real engine (convex-test)', () => {
146
+ it('each scenario’s expectations hold; nothing printed holds the secret; no runner row is left', async () => {
147
+ const core = await runner()
148
+ expect(core.INGEST_SCENARIOS.map((scenario) => scenario.name)).toEqual(['ec671', 'ec685-causal', 'ec685-release', 'storage-sha256', 'ec618-burst', 'ec695'])
149
+ for (const scenario of core.INGEST_SCENARIOS) {
150
+ const t = await backend()
151
+ const lines: string[] = []
152
+ const result = await core.runIngestScenario(scenario, port(core, t), {
153
+ say: (line) => lines.push(line),
154
+ wire,
155
+ runId: `run-${scenario.name}`,
156
+ now: () => new Date().toISOString(),
157
+ })
158
+ expect({ scenario: scenario.name, ...result, lines }).toMatchObject({ scenario: scenario.name, ok: true, failed: [] })
159
+ expect(result.passed).toBeGreaterThan(3)
160
+ expect(lines.join('\n').includes(PLANTED)).toBe(false)
161
+ expect(lines.join('\n')).not.toMatch(/https?:\/\//)
162
+ expect(await leftovers(t)).toEqual({ submissions: 0, bindings: 0, assets: 0 })
163
+ }
164
+ })
165
+
166
+ it('the encoding scenario names convex-test’s form: base64', async () => {
167
+ const core = await runner()
168
+ const t = await backend()
169
+ const lines: string[] = []
170
+ const scenario = core.INGEST_SCENARIOS.find((entry) => entry.name === 'storage-sha256')!
171
+ expect((await core.runIngestScenario(scenario, port(core, t), { say: (line) => lines.push(line), wire, runId: 'r', now: () => T0 })).ok).toBe(true)
172
+ expect(lines).toContain(' · _storage.sha256 form (hex, base64, or unreadable): base64')
173
+ expect(lines).toContain(' · _storage.sha256 length: 44')
174
+ })
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
+
198
+ it('negative control: signed under ANOTHER secret, every scenario fails visibly (the receiver refuses it)', async () => {
199
+ const core = await runner()
200
+ for (const scenario of core.INGEST_SCENARIOS) {
201
+ const t = await backend()
202
+ const lines: string[] = []
203
+ const result = await core.runIngestScenario(scenario, port(core, t, 'not-the-deployment-secret'), {
204
+ say: (line) => lines.push(line),
205
+ wire,
206
+ runId: 'r',
207
+ now: () => T0,
208
+ })
209
+ expect(result.ok, scenario.name).toBe(false)
210
+ expect(lines.some((line) => /✗ .*: 401 \(expected 200\)/.test(line)), scenario.name).toBe(true)
211
+ expect(await leftovers(t)).toMatchObject({ submissions: 0, assets: 0 })
212
+ }
213
+ })
214
+ })
@@ -4,7 +4,7 @@ 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, signedIngestRequest } from '@elytracms/core/ingest'
7
+ import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
8
8
  import type { IngestReceiverBody } from '@elytracms/core/ingest'
9
9
  import { InMemoryRecordStore } from '@elytracms/core/persistence'
10
10
  import type { RecordRepository } from '@elytracms/core/persistence'
@@ -372,8 +372,11 @@ describe('the editor’s writes (the kind’s save)', () => {
372
372
 
373
373
  // --- Uploads -----------------------------------------------------------------------------------
374
374
 
375
- async function declareUpload(t: Backend, contentType: string, size: number, field = 'cv') {
376
- const answer = await post(t, { kind: 'upload', target: 'contact', field, contentType, size })
375
+ /** An upload intent declaring the file's type, size and (EC-685) the SHA-256 of the zero-filled `bytesOf` bytes the test stores. */
376
+ async function declareUpload(t: Backend, contentType: string, size: number, field = 'cv', bytesOf = size) {
377
+ const digest = await ingestUploadSha256(new Uint8Array(bytesOf))
378
+ if (!digest.ok) throw new Error(digest.message)
379
+ const answer = await post(t, { kind: 'upload', target: 'contact', field, contentType, size, sha256: digest.sha256 })
377
380
  return { status: answer.status, body: (await answer.json()) as { uploadId?: string; uploadUrl?: string } }
378
381
  }
379
382
 
@@ -381,7 +384,7 @@ describe('uploads, claimed by the submission', () => {
381
384
  it('a claimed file is on the row (name, type, size, URL), its URL reaches the mail; removing the row deletes it', async () => {
382
385
  const t = await backend()
383
386
  const { mails } = site()
384
- const { status, body } = await declareUpload(t, 'application/pdf', 900)
387
+ const { status, body } = await declareUpload(t, 'application/pdf', 900, 'cv', 800)
385
388
  expect(status).toBe(200)
386
389
  expect(typeof body.uploadUrl).toBe('string')
387
390
  const storageId = await storeFile(t, 800, 'application/pdf')
@@ -406,12 +409,12 @@ describe('uploads, claimed by the submission', () => {
406
409
  expect(await t.run((ctx) => ctx.storage.get(storageId as never))).toBeNull()
407
410
  })
408
411
 
409
- it('a claim that fails refuses the submission AND deletes the files it had already claimed (never the refused one)', async () => {
412
+ 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 () => {
410
413
  const t = await backend()
411
414
  site()
412
- const good = await declareUpload(t, 'image/png', 500, 'photo')
415
+ const good = await declareUpload(t, 'image/png', 500, 'photo', 400)
413
416
  const goodFile = await storeFile(t, 400, 'image/png')
414
- const liar = await declareUpload(t, 'image/png', 500, 'photo')
417
+ const liar = await declareUpload(t, 'image/png', 500, 'photo', 10) // its own bytes: the mismatch is the type
415
418
  const wrongType = await storeFile(t, 10, 'text/html')
416
419
  const answer = await post(t, {
417
420
  ...SUBMISSION,
@@ -422,10 +425,15 @@ describe('uploads, claimed by the submission', () => {
422
425
  })
423
426
  expect(answer.status).toBe(415)
424
427
  expect(await rows(t)).toEqual([])
425
- expect(await t.run((ctx) => ctx.storage.get(goodFile as never))).toBeNull() // claimed, then released
428
+ // EC-695: every claim judged before any is bound — the passing one was never bound, so there is
429
+ // nothing to release (before: claimed, then released and deleted by the forms rollback).
430
+ expect(await t.run(async (ctx) => (await ctx.storage.get(goodFile as never)) !== null)).toBe(true)
431
+ expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'ingestBinding'))).toEqual([])
426
432
  // EC-671: the refused claim never deletes the id it names (the visitor's word, not an ownership fact).
427
433
  expect(await t.run(async (ctx) => (await ctx.storage.get(wrongType as never)) !== null)).toBe(true)
428
434
  expect(await scheduled(t)).toEqual([])
435
+ // The passing upload's intent was never spent: the corrected submission claims it.
436
+ expect((await post(t, { ...SUBMISSION, uploads: [{ uploadId: good.body.uploadId!, storageId: goodFile }] })).status).toBe(200)
429
437
  })
430
438
 
431
439
  it('the forms upload policy refuses what a browser would run (HTML, SVG) and anything over 10 MiB', async () => {
@@ -457,10 +465,16 @@ describe('the site’s form post, end to end', () => {
457
465
  const t = await backend()
458
466
  const viaReceiver = (async (url: string, init: RequestInit) => t.fetch(new URL(url).pathname, init)) as unknown as typeof fetch
459
467
  const ingest = createIngestRoute({ secret: SECRET, siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
460
- // The bytes' destination: the issued storage URL (convex-test stores them directly).
468
+ // The bytes' destination: the issued storage URL — convex-test stores THE POSTED BYTES, so
469
+ // the digest the deployment computes is of what the form sent (EC-685: the form hashed them).
461
470
  const storeBytes = (async (_url: string, init: RequestInit) => {
462
471
  const blob = init.body as Blob
463
- const storageId = await storeFile(t, blob.size, String(new Headers(init.headers).get('content-type')))
472
+ const contentType = String(new Headers(init.headers).get('content-type'))
473
+ const storageId = await t.run(async (ctx) => {
474
+ const id = await ctx.storage.store(new Blob([await blob.arrayBuffer()], { type: contentType }))
475
+ await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
476
+ return id
477
+ })
464
478
  return Response.json({ storageId })
465
479
  }) as unknown as typeof fetch
466
480
  const formPost = createFormPostRoute({ ingest, fetchImpl: storeBytes })
@@ -470,7 +484,7 @@ describe('the site’s form post, end to end', () => {
470
484
  form.set('email', 'ada@example.test')
471
485
  form.set('message', 'Please call back.')
472
486
  form.set('website', '')
473
- form.set('cv', new File([new Uint8Array(300)], 'cv.pdf', { type: 'application/pdf' }))
487
+ form.set('cv', new File([new TextEncoder().encode('%PDF-1.7 Ada Lovelace, CV '.padEnd(300, '.'))], 'cv.pdf', { type: 'application/pdf' }))
474
488
  const answer = await formPost.POST(
475
489
  new Request('https://site.test/api/form', {
476
490
  method: 'POST',
@@ -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`);
@@ -136,7 +139,8 @@ export const save = mutation({
136
139
  /**
137
140
  * Delete a submission AND the files it claimed (no claimed bytes outlive their row). EC-671:
138
141
  * through `releaseIngestUploads`, which deletes only files a claim bound — a `files` entry
139
- * naming anything else (an editor's write can put any id there) is kept, never deleted.
142
+ * naming anything else (an editor's write can put any id there) is kept, never deleted — and,
143
+ * EC-685, keeps a claimed file an editor has since made an asset of (the asset owns it now).
140
144
  */
141
145
  export const remove = mutation({
142
146
  args: { [kind.idKey]: v.string() },
@@ -174,16 +178,14 @@ export const ingest = internalMutation({
174
178
  const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
175
179
  if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
176
180
 
177
- // Claim every upload; on the first refusal, release what this submission already claimed
178
- // (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
179
186
  const files: SubmissionFile[] = []
180
187
  const namesUsed = new Map<string, number>()
181
- for (const claim of body.uploads ?? []) {
182
- const claimed = await claimIngestUpload(ctx, claim, body.target, receivedAt)
183
- if (!claimed.ok) {
184
- await releaseIngestUploads(ctx, storageIds(files))
185
- return claimed
186
- }
188
+ for (const claimed of claim.files) {
187
189
  const index = namesUsed.get(claimed.field) ?? 0
188
190
  namesUsed.set(claimed.field, index + 1)
189
191
  const name = fileNames(read.columns.payload, claimed.field)[index]
@@ -0,0 +1,406 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import { INGEST_RECEIVER_PATH, INGEST_SIGNATURE_HEADER, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
6
+ import type { IngestReceiverBody } from '@elytracms/core/ingest'
7
+ import { signRevalidationBody } from '@elytracms/core/revalidation'
8
+ import schema from '../../../studio/convex/schema'
9
+ import * as assetsModule from '../../../studio/convex/assets'
10
+ import * as deliveryModule from '../../../studio/convex/delivery'
11
+ import { defineStudioHttp } from '../../../studio/convex/http'
12
+ import { ConvexPersistenceAdapter } from '../../../studio/src/lib/persistence/convex-adapter'
13
+ import { deleteAsset } from '../../../studio/src/lib/assets/delete-asset'
14
+ import * as formsModule from './index'
15
+ import { submissionRecordKind } from '../shared/kind'
16
+ import type { SubmissionRecord } from '../shared/kind'
17
+ import { FORMS_DELIVERY_URL_ENV } from '../shared/delivery'
18
+ import { FORMS_HONEYPOT_KEY } from '../shared/submission'
19
+
20
+ /**
21
+ * EC-685 (Codex round 4 F20 residual) — EC-304 Phase B's next executable probes: ownership
22
+ * that is CAUSAL (the claim binds only bytes its own intent stored) and that STAYS true when
23
+ * a delete spends it. Both orderings exactly as Codex wrote them, through the real paths —
24
+ * the signed receiver, the forms claim and removal, the real `assets:upsert` and the studio's
25
+ * own `deleteAsset` (bytes first, through `assets:deleteBlob`). Every defect line says what
26
+ * the base (`6310b7b4`) did. Run only inside convex-test, generated fixture ids, an
27
+ * unreachable `.invalid` delivery host; nothing is sent.
28
+ */
29
+
30
+ const SECRET = 'ec685-ingest-secret'
31
+ const PROJECT_ID = 'forms-test'
32
+ const ADMIN = 'user_admin'
33
+ const T0 = '2026-10-28T12:00:00.000Z'
34
+
35
+ function env(): Record<string, string | undefined> {
36
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
37
+ }
38
+
39
+ beforeEach(() => {
40
+ vi.useFakeTimers()
41
+ vi.setSystemTime(new Date(T0))
42
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
43
+ env()[FORMS_DELIVERY_URL_ENV] = 'https://site.invalid/api/forms'
44
+ })
45
+ afterEach(() => {
46
+ vi.useRealTimers()
47
+ delete env()['ELYTRA_INGEST_SECRET']
48
+ delete env()[FORMS_DELIVERY_URL_ENV]
49
+ })
50
+
51
+ const silentAuth = { addHttpRoutes: () => {} }
52
+ const modules = {
53
+ '/convex/_generated/api.ts': async () => ({}),
54
+ '/convex/assets.ts': async () => assetsModule,
55
+ '/convex/delivery.ts': async () => deliveryModule,
56
+ '/convex/forms.ts': async () => formsModule,
57
+ '/convex/http.ts': async () => ({
58
+ default: defineStudioHttp({ auth: silentAuth, ingest: { submission: formsModule.formsIngest } }),
59
+ }),
60
+ }
61
+
62
+ async function backend() {
63
+ const t = convexTest(schema, modules)
64
+ await t.run(async (ctx) => {
65
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: T0, updatedAt: T0 })
66
+ })
67
+ return t
68
+ }
69
+ type Backend = Awaited<ReturnType<typeof backend>>
70
+
71
+ const asAdmin = (t: Backend) => t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
72
+
73
+ /** The studio's own adapter over the deployment, signed in as an editor (the admin). */
74
+ function studio(t: Backend) {
75
+ const client = {
76
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin(t).query(ref, args ?? {}),
77
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin(t).mutation(ref, args ?? {}),
78
+ } as unknown as ConvexHttpClient
79
+ return new ConvexPersistenceAdapter(client, PROJECT_ID)
80
+ }
81
+
82
+ async function post(t: Backend, body: IngestReceiverBody) {
83
+ const signed = await signedIngestRequest(body, SECRET)
84
+ return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
85
+ }
86
+
87
+ /** File content: distinct bytes per label (a victim's CV is not predictable by an attacker). */
88
+ const content = (label: string, size: number) => {
89
+ const bytes = new Uint8Array(size)
90
+ const seed = new TextEncoder().encode(label)
91
+ for (let index = 0; index < size; index += 1) bytes[index] = seed[index % seed.length]! ^ (index & 0xff)
92
+ return bytes
93
+ }
94
+
95
+ /** Bytes as an issued upload URL stores them (convex-test records size only; the real upload records the type). */
96
+ async function storeFile(t: Backend, bytes: Uint8Array<ArrayBuffer>, contentType: string): Promise<string> {
97
+ return t.run(async (ctx) => {
98
+ const id = await ctx.storage.store(new Blob([bytes], { type: contentType }))
99
+ await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
100
+ return id
101
+ })
102
+ }
103
+
104
+ /** The digest an uploader declares for `bytes` (the one encoder, `@elytracms/core/ingest`). */
105
+ async function sha256Of(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
106
+ const digest = await ingestUploadSha256(bytes)
107
+ if (!digest.ok) throw new Error(digest.message)
108
+ return digest.sha256
109
+ }
110
+
111
+ /** An upload intent from the receiver, declaring the file the uploader holds (`bytes`): its type, size and SHA-256. */
112
+ async function declare(t: Backend, bytes: Uint8Array<ArrayBuffer>, contentType: string, size = bytes.byteLength): Promise<string> {
113
+ const answer = await post(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size, sha256: await sha256Of(bytes) })
114
+ expect(answer.status).toBe(200)
115
+ return ((await answer.json()) as { uploadId: string }).uploadId
116
+ }
117
+
118
+ const SUBMISSION = {
119
+ kind: 'submission',
120
+ target: 'contact',
121
+ data: { email: 'ec685@example.test', message: 'hi', cv: 'cv.pdf', [FORMS_HONEYPOT_KEY]: '' },
122
+ } as const
123
+
124
+ const claim = (t: Backend, uploadId: string, storageId: string) => post(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
125
+
126
+ async function rows(t: Backend): Promise<SubmissionRecord[]> {
127
+ return (await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'submission'))).map(
128
+ (row) => ({ ...(row.data as object), id: row.recordId }) as unknown as SubmissionRecord,
129
+ )
130
+ }
131
+
132
+ const bindings = (t: Backend) =>
133
+ t.run(async (ctx) =>
134
+ (await ctx.db.query('records').collect())
135
+ .filter((row) => row.kind === 'ingestBinding')
136
+ .map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
137
+ )
138
+
139
+ /** Whether stored bytes still exist (`storage.get` → a Blob, which is not a Convex value: answer a boolean). */
140
+ const survives = (t: Backend, storageId: string) => t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
141
+
142
+ /** A genuine upload, claimed and bound: its own intent, its own bytes. Returns the row and the file. */
143
+ async function genuineSubmission(t: Backend, label: string): Promise<{ rowId: string; file: string }> {
144
+ const bytes = content(label, 800)
145
+ const uploadId = await declare(t, bytes, 'application/pdf')
146
+ const file = await storeFile(t, bytes, 'application/pdf')
147
+ const answer = await claim(t, uploadId, file)
148
+ expect(answer.status).toBe(200)
149
+ return { rowId: ((await answer.json()) as { id: string }).id, file }
150
+ }
151
+
152
+ /** An editor promotes the submission's file to an asset — `assets:upsert` over the bound storage id (a legitimate edit). */
153
+ async function promote(t: Backend, assetId: string, file: string): Promise<void> {
154
+ await studio(t).assets.upsertAsset(PROJECT_ID, {
155
+ id: assetId,
156
+ filename: `${assetId}.pdf`,
157
+ contentType: 'application/pdf',
158
+ byteSize: 800,
159
+ storageKey: file,
160
+ url: `https://assets.invalid/${assetId}.pdf`,
161
+ })
162
+ }
163
+
164
+ /** The studio's own asset delete (bytes first, through `assets:deleteBlob`; then the row). */
165
+ function deleteAssetInStudio(t: Backend, assetId: string) {
166
+ const adapter = studio(t)
167
+ return deleteAsset(
168
+ { assets: adapter.assets, blobs: adapter.blobs, references: { inbound: async () => [] } },
169
+ PROJECT_ID,
170
+ assetId,
171
+ { confirmInUse: true },
172
+ )
173
+ }
174
+
175
+ const assetIds = (t: Backend) => t.run(async (ctx) => (await ctx.db.query('assets').collect()).map((row) => row.assetId))
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
+
204
+ describe('EC-685 probe (1) — causation: a claim binds only bytes its own intent stored', () => {
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 () => {
206
+ const t = await backend()
207
+ const victim = content('B: the victim’s own CV', 800)
208
+ const attacker = content('A: whatever the attacker holds', 800)
209
+ const a = await declare(t, attacker, 'application/pdf') // A's facts match B's file in type and size…
210
+ const b = await declare(t, victim, 'application/pdf')
211
+ const file = await storeFile(t, victim, 'application/pdf') // …and the bytes go through B's URL, after both intents
212
+ const byA = await claim(t, a, file)
213
+ expect(byA.status).toBe(422) // base: 200 — A bound B's file (eligibility checks only: after A, unbound, no asset's)
214
+ expect(await bindings(t)).toEqual([])
215
+ const byB = await claim(t, b, file)
216
+ expect(byB.status).toBe(200) // base: 422 — the file was A's binding already
217
+ expect(await bindings(t)).toEqual([{ storageId: file, uploadId: b }])
218
+ expect((await rows(t)).map((row) => row.files?.[0]?.storageId)).toEqual([file])
219
+ })
220
+
221
+ it('the claim reads the deployment’s stored digest by its form: convex-test stores BASE64 (measured), the intent declares hex', async () => {
222
+ const t = await backend()
223
+ const bytes = content('a CV', 800)
224
+ const file = await storeFile(t, bytes, 'application/pdf')
225
+ const stored = await t.run(async (ctx) => ((await ctx.db.system.get(file as never)) as { sha256: string }).sha256)
226
+ expect(stored).toMatch(/^[A-Za-z0-9+/]{43}=$/) // convex-test 0.0.54 `blobSha`: btoa(…)
227
+ expect(await sha256Of(bytes)).toMatch(/^[0-9a-f]{64}$/)
228
+ })
229
+
230
+ it('RESIDUAL (named, not closed): bytes an attacker can PREDICT can be declared — A binds them first', async () => {
231
+ // Causal only for unpredictable bytes: a deterministic file (one an addon generates, a
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.
236
+ const t = await backend()
237
+ const predictable = content('a template every visitor uploads', 800)
238
+ const a = await declare(t, predictable, 'application/pdf')
239
+ const b = await declare(t, predictable, 'application/pdf')
240
+ const file = await storeFile(t, predictable, 'application/pdf')
241
+ expect((await claim(t, a, file)).status).toBe(200)
242
+ expect((await claim(t, b, file)).status).toBe(422) // bound already (check 2)
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
246
+ })
247
+
248
+ it('control: a genuine claim binds (its own intent, its own bytes)', async () => {
249
+ const t = await backend()
250
+ const { file } = await genuineSubmission(t, 'a genuine CV')
251
+ expect((await bindings(t)).map((binding) => binding.storageId)).toEqual([file])
252
+ })
253
+ })
254
+
255
+ describe('EC-685 probe (2) — ownership stays true when a delete spends it', () => {
256
+ it('a claimed, bound file; an asset is upserted over it; the submission is removed: the asset’s file survives', async () => {
257
+ const t = await backend()
258
+ const { rowId, file } = await genuineSubmission(t, 'a CV the editor keeps')
259
+ await promote(t, 'promoted-cv', file)
260
+ await studio(t).records(submissionRecordKind).remove(PROJECT_ID, rowId)
261
+ expect(await rows(t)).toEqual([])
262
+ expect(await survives(t, file)).toBe(true) // base: false — release deleted a file an asset now references
263
+ expect(await bindings(t)).toEqual([]) // ownership passed to the asset: the binding is spent
264
+ // …and the asset owns it now: deleting the asset deletes the file.
265
+ expect(await deleteAssetInStudio(t, 'promoted-cv')).toEqual({ outcome: 'deleted' })
266
+ expect(await survives(t, file)).toBe(false)
267
+ })
268
+
269
+ it('mirror: the asset is deleted while the binding holds: the submission’s file survives, the asset row goes', async () => {
270
+ const t = await backend()
271
+ const { rowId, file } = await genuineSubmission(t, 'a CV the editor un-promotes')
272
+ await promote(t, 'promoted-cv', file)
273
+ expect(await deleteAssetInStudio(t, 'promoted-cv')).toEqual({ outcome: 'deleted' })
274
+ expect(await assetIds(t)).toEqual([])
275
+ expect(await survives(t, file)).toBe(true) // base: false — deleteBlob deleted bytes an ingest binding holds
276
+ // …and the submission still owns it: removing the submission deletes the file.
277
+ await studio(t).records(submissionRecordKind).remove(PROJECT_ID, rowId)
278
+ expect(await survives(t, file)).toBe(false)
279
+ expect(await bindings(t)).toEqual([])
280
+ })
281
+
282
+ it('control: a genuine release of an unreferenced bound file deletes it (the instrument sees a deletion)', async () => {
283
+ const t = await backend()
284
+ const { rowId, file } = await genuineSubmission(t, 'a CV nobody promoted')
285
+ expect(await survives(t, file)).toBe(true)
286
+ await studio(t).records(submissionRecordKind).remove(PROJECT_ID, rowId)
287
+ expect(await survives(t, file)).toBe(false)
288
+ expect(await bindings(t)).toEqual([])
289
+ })
290
+
291
+ it('control: an asset nobody bound is deleted with its bytes (the instrument sees deleteBlob delete)', async () => {
292
+ const t = await backend()
293
+ const file = await storeFile(t, content('an editor’s own upload', 800), 'application/pdf')
294
+ await promote(t, 'own-asset', file)
295
+ expect(await deleteAssetInStudio(t, 'own-asset')).toEqual({ outcome: 'deleted' })
296
+ expect(await survives(t, file)).toBe(false)
297
+ })
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
+ })