@elytracms/forms 0.0.99 → 0.0.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,9 +32,10 @@ export function formPayload(entries, options = {}) {
32
32
  }
33
33
  values.set(name, [...(values.get(name) ?? []), value]);
34
34
  }
35
- const data = {};
36
- for (const [name, list] of values)
37
- data[name] = list.length === 1 ? list[0] : list;
35
+ // EC-704: every posted name becomes an OWN entry — `Object.fromEntries` DEFINES each property.
36
+ // An assignment (`data[name] = …`) would call the prototype setter for a field named
37
+ // `__proto__` and lose that answer silently; kept, the name rule refuses it visibly below.
38
+ const data = Object.fromEntries([...values].map(([name, list]) => [name, list.length === 1 ? list[0] : list]));
38
39
  if (options.sourceUrl)
39
40
  data[FORMS_SOURCE_KEY] = options.sourceUrl;
40
41
  data[FORMS_HONEYPOT_KEY] = honeypotValue;
@@ -1 +1 @@
1
- {"version":3,"file":"submit.js","sourceRoot":"","sources":["../../src/next/submit.ts"],"names":[],"mappings":"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","sourcesContent":["import { ingestUploadSha256 } from '@elytracms/core/ingest'\nimport {\n DEFAULT_HONEYPOT_FIELD,\n FORMS_HONEYPOT_KEY,\n FORMS_RESERVED_PREFIX,\n FORMS_SOURCE_KEY,\n FORMS_TARGET_FIELD,\n formFieldNameMessage,\n formFieldNameProblem,\n} from '../shared/submission'\n\n/**\n * EC-613 — a form post as the ingest seam's calls (EC-617): one `upload` per file (the route\n * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one\n * `submission` that claims the stored files. The ONE flow, with injected transports, so the\n * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send\n * exactly the same calls — and EC-685: hash every file in the same place, so each `upload`\n * declares its bytes' SHA-256 (the claim binds a stored file only when its digest is the\n * declared one).\n */\n\n/** A posted value: text, or a file (a `File` / `Blob` with a name). */\ntype PostedValue = string | Blob\n\n/** The form's fields as the ingest payload carries them, and the files to upload. */\nexport interface FormPayload {\n data: Record<string, string | string[]>\n files: { field: string; file: Blob; name: string }[]\n}\n\nexport interface FormPayloadOptions {\n /** The honeypot field the form renders (default `website`); its value goes to `elytra:honeypot`. */\n honeypot?: string\n /** The page the form was submitted from (`$source`). */\n sourceUrl?: string\n}\n\nfunction isFile(value: PostedValue): value is Blob {\n return typeof value !== 'string'\n}\n\n/**\n * Read a form's entries (pure): repeated names become a list, the honeypot moves to\n * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is\n * an upload whose NAME is the field's value (what the inbox and the mail list). A file input\n * left empty posts a nameless zero-byte part — it is skipped.\n */\nexport function formPayload(entries: Iterable<[string, PostedValue]>, options: FormPayloadOptions = {}): FormPayload {\n const honeypot = options.honeypot ?? DEFAULT_HONEYPOT_FIELD\n const values = new Map<string, string[]>()\n const files: FormPayload['files'] = []\n let honeypotValue = ''\n for (const [name, value] of entries) {\n if (name === FORMS_TARGET_FIELD) continue\n if (name === honeypot) {\n if (typeof value === 'string') honeypotValue = value\n continue\n }\n if (isFile(value)) {\n if (value.size === 0) continue\n const fileName = (value as Blob & { name?: string }).name || 'file'\n files.push({ field: name, file: value, name: fileName })\n values.set(name, [...(values.get(name) ?? []), fileName])\n continue\n }\n values.set(name, [...(values.get(name) ?? []), value])\n }\n const data: FormPayload['data'] = {}\n for (const [name, list] of values) data[name] = list.length === 1 ? list[0]! : list\n if (options.sourceUrl) data[FORMS_SOURCE_KEY] = options.sourceUrl\n data[FORMS_HONEYPOT_KEY] = honeypotValue\n return { data, files }\n}\n\n/** What a submit came to. */\nexport type SubmitResult =\n | { ok: true; id: string }\n | {\n ok: false\n /** Which call failed: an upload URL, the bytes, or the submission. */\n stage: 'upload' | 'storage' | 'submission'\n status: number\n error: string\n issues?: { path: (string | number)[]; message: string }[]\n }\n\n/** The two transports the flow runs over. */\nexport interface SubmitTransport {\n /** Send one ingest payload through the site's ingest route; resolve with its answer. */\n ingest(payload: Record<string, unknown>): Promise<Response>\n /** Send one file's bytes to an issued storage URL; resolve with the storage's answer (`{ storageId }`). */\n store(uploadUrl: string, file: Blob, contentType: string): Promise<Response>\n}\n\nasync function readJson(response: Response): Promise<Record<string, unknown>> {\n try {\n const parsed = (await response.json()) as unknown\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}\n } catch {\n return {}\n }\n}\n\nfunction failure(stage: 'upload' | 'storage' | 'submission', status: number, body: Record<string, unknown>, fallback: string): SubmitResult {\n return {\n ok: false,\n stage,\n status,\n error: typeof body['error'] === 'string' ? body['error'] : fallback,\n ...(Array.isArray(body['issues']) ? { issues: body['issues'] as { path: (string | number)[]; message: string }[] } : {}),\n }\n}\n\n/**\n * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file\n * uploaded first, then the submission claiming them. The first failure ends it and is\n * returned with its stage and status; nothing is retried here.\n *\n * EC-685: every file's SHA-256 is computed FIRST, here — the one hashing site of both flows —\n * and declared with its `upload`. A browser computes it only on a secure page (WebCrypto,\n * https or localhost); elsewhere the post is refused before anything is sent (stage `upload`,\n * status 400, the reason in `error`), never sent without the digest.\n */\nexport async function sendFormSubmission(input: { target: string; payload: FormPayload; transport: SubmitTransport }): Promise<SubmitResult> {\n const { target, payload, transport } = input\n // A name the envelope (or the forms addon) would refuse is refused here, before anything is\n // sent — with the same reason, as the same `['data', name]` issues the route's 400 carries.\n const names = new Set([...Object.keys(payload.data), ...payload.files.map((file) => file.field)])\n const issues = [...names].flatMap((name) => {\n if (name.startsWith(FORMS_RESERVED_PREFIX)) return []\n const problem = formFieldNameProblem(name)\n return problem ? [{ path: ['data', name] as (string | number)[], message: formFieldNameMessage(name, problem) }] : []\n })\n if (issues.length > 0) {\n return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues }\n }\n const digests: string[] = []\n for (const { file } of payload.files) {\n const digest = await ingestUploadSha256(file)\n if (!digest.ok) return { ok: false, stage: 'upload', status: 400, error: digest.message }\n digests.push(digest.sha256)\n }\n const uploads: { uploadId: string; storageId: string }[] = []\n for (const [index, { field, file }] of payload.files.entries()) {\n // The type the browser reports; an unknown one is declared as an opaque binary.\n const contentType = file.type || 'application/octet-stream'\n const sha256 = digests[index]!\n const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size, sha256 })\n const issuedBody = await readJson(issued)\n if (!issued.ok) return failure('upload', issued.status, issuedBody, 'The upload was refused.')\n const uploadId = issuedBody['uploadId']\n const uploadUrl = issuedBody['uploadUrl']\n if (typeof uploadId !== 'string' || typeof uploadUrl !== 'string') {\n return failure('upload', 502, {}, 'The upload answer carried no upload URL.')\n }\n const stored = await transport.store(uploadUrl, file, contentType)\n const storedBody = await readJson(stored)\n if (!stored.ok || typeof storedBody['storageId'] !== 'string') {\n return failure('storage', stored.ok ? 502 : stored.status, storedBody, 'The file could not be stored.')\n }\n uploads.push({ uploadId, storageId: storedBody['storageId'] })\n }\n const sent = await transport.ingest({\n kind: 'submission',\n target,\n data: payload.data,\n ...(uploads.length > 0 ? { uploads } : {}),\n })\n const body = await readJson(sent)\n if (!sent.ok) return failure('submission', sent.status, body, 'The submission was refused.')\n return { ok: true, id: typeof body['id'] === 'string' ? body['id'] : '' }\n}\n\n/**\n * Submit a form from the BROWSER (a client component's submit handler): its fields and files\n * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the\n * deployment's storage — so an upload is not bound by the site's function body limit.\n *\n * ```tsx\n * const result = await submitForm({ form: event.currentTarget, target: 'contact' })\n * ```\n */\nexport function submitForm(input: {\n form: HTMLFormElement | FormData\n target: string\n /** The site's ingest route (default `/api/ingest`). */\n endpoint?: string\n honeypot?: string\n /** Default: the current page. */\n sourceUrl?: string\n fetchImpl?: typeof fetch\n}): Promise<SubmitResult> {\n const fetchImpl = input.fetchImpl ?? fetch\n const formData = input.form instanceof FormData ? input.form : new FormData(input.form)\n const sourceUrl = input.sourceUrl ?? (typeof location === 'undefined' ? undefined : location.href)\n const payload = formPayload(formData.entries() as Iterable<[string, PostedValue]>, {\n ...(input.honeypot ? { honeypot: input.honeypot } : {}),\n ...(sourceUrl ? { sourceUrl } : {}),\n })\n const endpoint = input.endpoint ?? '/api/ingest'\n return sendFormSubmission({\n target: input.target,\n payload,\n transport: {\n ingest: (body) =>\n fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),\n store: (uploadUrl, file, contentType) =>\n fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),\n },\n })\n}\n"]}
1
+ {"version":3,"file":"submit.js","sourceRoot":"","sources":["../../src/next/submit.ts"],"names":[],"mappings":"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","sourcesContent":["import { ingestUploadSha256 } from '@elytracms/core/ingest'\nimport {\n DEFAULT_HONEYPOT_FIELD,\n FORMS_HONEYPOT_KEY,\n FORMS_RESERVED_PREFIX,\n FORMS_SOURCE_KEY,\n FORMS_TARGET_FIELD,\n formFieldNameMessage,\n formFieldNameProblem,\n} from '../shared/submission'\n\n/**\n * EC-613 — a form post as the ingest seam's calls (EC-617): one `upload` per file (the route\n * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one\n * `submission` that claims the stored files. The ONE flow, with injected transports, so the\n * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send\n * exactly the same calls — and EC-685: hash every file in the same place, so each `upload`\n * declares its bytes' SHA-256 (the claim binds a stored file only when its digest is the\n * declared one).\n */\n\n/** A posted value: text, or a file (a `File` / `Blob` with a name). */\ntype PostedValue = string | Blob\n\n/** The form's fields as the ingest payload carries them, and the files to upload. */\nexport interface FormPayload {\n data: Record<string, string | string[]>\n files: { field: string; file: Blob; name: string }[]\n}\n\nexport interface FormPayloadOptions {\n /** The honeypot field the form renders (default `website`); its value goes to `elytra:honeypot`. */\n honeypot?: string\n /** The page the form was submitted from (`$source`). */\n sourceUrl?: string\n}\n\nfunction isFile(value: PostedValue): value is Blob {\n return typeof value !== 'string'\n}\n\n/**\n * Read a form's entries (pure): repeated names become a list, the honeypot moves to\n * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is\n * an upload whose NAME is the field's value (what the inbox and the mail list). A file input\n * left empty posts a nameless zero-byte part — it is skipped.\n */\nexport function formPayload(entries: Iterable<[string, PostedValue]>, options: FormPayloadOptions = {}): FormPayload {\n const honeypot = options.honeypot ?? DEFAULT_HONEYPOT_FIELD\n const values = new Map<string, string[]>()\n const files: FormPayload['files'] = []\n let honeypotValue = ''\n for (const [name, value] of entries) {\n if (name === FORMS_TARGET_FIELD) continue\n if (name === honeypot) {\n if (typeof value === 'string') honeypotValue = value\n continue\n }\n if (isFile(value)) {\n if (value.size === 0) continue\n const fileName = (value as Blob & { name?: string }).name || 'file'\n files.push({ field: name, file: value, name: fileName })\n values.set(name, [...(values.get(name) ?? []), fileName])\n continue\n }\n values.set(name, [...(values.get(name) ?? []), value])\n }\n // EC-704: every posted name becomes an OWN entry — `Object.fromEntries` DEFINES each property.\n // An assignment (`data[name] = …`) would call the prototype setter for a field named\n // `__proto__` and lose that answer silently; kept, the name rule refuses it visibly below.\n const data: FormPayload['data'] = Object.fromEntries(\n [...values].map(([name, list]): [string, string | string[]] => [name, list.length === 1 ? list[0]! : list]),\n )\n if (options.sourceUrl) data[FORMS_SOURCE_KEY] = options.sourceUrl\n data[FORMS_HONEYPOT_KEY] = honeypotValue\n return { data, files }\n}\n\n/** What a submit came to. */\nexport type SubmitResult =\n | { ok: true; id: string }\n | {\n ok: false\n /** Which call failed: an upload URL, the bytes, or the submission. */\n stage: 'upload' | 'storage' | 'submission'\n status: number\n error: string\n issues?: { path: (string | number)[]; message: string }[]\n }\n\n/** The two transports the flow runs over. */\nexport interface SubmitTransport {\n /** Send one ingest payload through the site's ingest route; resolve with its answer. */\n ingest(payload: Record<string, unknown>): Promise<Response>\n /** Send one file's bytes to an issued storage URL; resolve with the storage's answer (`{ storageId }`). */\n store(uploadUrl: string, file: Blob, contentType: string): Promise<Response>\n}\n\nasync function readJson(response: Response): Promise<Record<string, unknown>> {\n try {\n const parsed = (await response.json()) as unknown\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}\n } catch {\n return {}\n }\n}\n\nfunction failure(stage: 'upload' | 'storage' | 'submission', status: number, body: Record<string, unknown>, fallback: string): SubmitResult {\n return {\n ok: false,\n stage,\n status,\n error: typeof body['error'] === 'string' ? body['error'] : fallback,\n ...(Array.isArray(body['issues']) ? { issues: body['issues'] as { path: (string | number)[]; message: string }[] } : {}),\n }\n}\n\n/**\n * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file\n * uploaded first, then the submission claiming them. The first failure ends it and is\n * returned with its stage and status; nothing is retried here.\n *\n * EC-685: every file's SHA-256 is computed FIRST, here — the one hashing site of both flows —\n * and declared with its `upload`. A browser computes it only on a secure page (WebCrypto,\n * https or localhost); elsewhere the post is refused before anything is sent (stage `upload`,\n * status 400, the reason in `error`), never sent without the digest.\n */\nexport async function sendFormSubmission(input: { target: string; payload: FormPayload; transport: SubmitTransport }): Promise<SubmitResult> {\n const { target, payload, transport } = input\n // A name the envelope (or the forms addon) would refuse is refused here, before anything is\n // sent — with the same reason, as the same `['data', name]` issues the route's 400 carries.\n const names = new Set([...Object.keys(payload.data), ...payload.files.map((file) => file.field)])\n const issues = [...names].flatMap((name) => {\n if (name.startsWith(FORMS_RESERVED_PREFIX)) return []\n const problem = formFieldNameProblem(name)\n return problem ? [{ path: ['data', name] as (string | number)[], message: formFieldNameMessage(name, problem) }] : []\n })\n if (issues.length > 0) {\n return { ok: false, stage: 'submission', status: 400, error: issues.map((issue) => issue.message).join(' '), issues }\n }\n const digests: string[] = []\n for (const { file } of payload.files) {\n const digest = await ingestUploadSha256(file)\n if (!digest.ok) return { ok: false, stage: 'upload', status: 400, error: digest.message }\n digests.push(digest.sha256)\n }\n const uploads: { uploadId: string; storageId: string }[] = []\n for (const [index, { field, file }] of payload.files.entries()) {\n // The type the browser reports; an unknown one is declared as an opaque binary.\n const contentType = file.type || 'application/octet-stream'\n const sha256 = digests[index]!\n const issued = await transport.ingest({ kind: 'upload', target, field, contentType, size: file.size, sha256 })\n const issuedBody = await readJson(issued)\n if (!issued.ok) return failure('upload', issued.status, issuedBody, 'The upload was refused.')\n const uploadId = issuedBody['uploadId']\n const uploadUrl = issuedBody['uploadUrl']\n if (typeof uploadId !== 'string' || typeof uploadUrl !== 'string') {\n return failure('upload', 502, {}, 'The upload answer carried no upload URL.')\n }\n const stored = await transport.store(uploadUrl, file, contentType)\n const storedBody = await readJson(stored)\n if (!stored.ok || typeof storedBody['storageId'] !== 'string') {\n return failure('storage', stored.ok ? 502 : stored.status, storedBody, 'The file could not be stored.')\n }\n uploads.push({ uploadId, storageId: storedBody['storageId'] })\n }\n const sent = await transport.ingest({\n kind: 'submission',\n target,\n data: payload.data,\n ...(uploads.length > 0 ? { uploads } : {}),\n })\n const body = await readJson(sent)\n if (!sent.ok) return failure('submission', sent.status, body, 'The submission was refused.')\n return { ok: true, id: typeof body['id'] === 'string' ? body['id'] : '' }\n}\n\n/**\n * Submit a form from the BROWSER (a client component's submit handler): its fields and files\n * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the\n * deployment's storage — so an upload is not bound by the site's function body limit.\n *\n * ```tsx\n * const result = await submitForm({ form: event.currentTarget, target: 'contact' })\n * ```\n */\nexport function submitForm(input: {\n form: HTMLFormElement | FormData\n target: string\n /** The site's ingest route (default `/api/ingest`). */\n endpoint?: string\n honeypot?: string\n /** Default: the current page. */\n sourceUrl?: string\n fetchImpl?: typeof fetch\n}): Promise<SubmitResult> {\n const fetchImpl = input.fetchImpl ?? fetch\n const formData = input.form instanceof FormData ? input.form : new FormData(input.form)\n const sourceUrl = input.sourceUrl ?? (typeof location === 'undefined' ? undefined : location.href)\n const payload = formPayload(formData.entries() as Iterable<[string, PostedValue]>, {\n ...(input.honeypot ? { honeypot: input.honeypot } : {}),\n ...(sourceUrl ? { sourceUrl } : {}),\n })\n const endpoint = input.endpoint ?? '/api/ingest'\n return sendFormSubmission({\n target: input.target,\n payload,\n transport: {\n ingest: (body) =>\n fetchImpl(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),\n store: (uploadUrl, file, contentType) =>\n fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),\n },\n })\n}\n"]}
@@ -16,9 +16,11 @@ import type { FormValue, SpamVerdict, SubmissionField } from './kind.js';
16
16
  *
17
17
  * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
18
18
  * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
19
- * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
20
- * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
21
- * site helpers refuse a name before sending, with the same reason the envelope would give.
19
+ * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver
20
+ * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored
21
+ * payload's `name`). The site helpers refuse a name before sending, with the same reason the
22
+ * envelope would give. There is no definition-time gate: a form's fields are CMS content
23
+ * (`source.ts`), so a refused name surfaces on submission, on both site paths.
22
24
  */
23
25
  /** The reserved namespace of the forms wire. */
24
26
  export declare const FORMS_RESERVED_PREFIX = "elytra:";
@@ -16,9 +16,11 @@ import { formValueSchema } from './kind.js';
16
16
  *
17
17
  * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
18
18
  * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
19
- * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
20
- * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
21
- * site helpers refuse a name before sending, with the same reason the envelope would give.
19
+ * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver
20
+ * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored
21
+ * payload's `name`). The site helpers refuse a name before sending, with the same reason the
22
+ * envelope would give. There is no definition-time gate: a form's fields are CMS content
23
+ * (`source.ts`), so a refused name surfaces on submission, on both site paths.
22
24
  */
23
25
  /** The reserved namespace of the forms wire. */
24
26
  export const FORMS_RESERVED_PREFIX = 'elytra:';
@@ -1 +1 @@
1
- {"version":3,"file":"submission.js","sourceRoot":"","sources":["../../src/shared/submission.ts"],"names":[],"mappings":"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","sourcesContent":["import { convexFieldNameProblem } from '@elytracms/core/ingest'\nimport type { IngestPayload } from '@elytracms/core/ingest'\nimport { formValueSchema } from './kind'\nimport type { FormValue, SpamVerdict, SubmissionField } from './kind'\n\n/**\n * EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`\n * payload carries for this addon, and the ONE reader the receiver's handler turns it into a\n * row with. Pure (no Convex), so the rules are unit-tested on their own.\n *\n * `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,\n * in the `elytra:` namespace:\n *\n * - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);\n * - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).\n *\n * Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are\n * not answers and are not stored; a submission with no answer and no file is refused.\n *\n * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name\n * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no\n * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with\n * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The\n * site helpers refuse a name before sending, with the same reason the envelope would give.\n */\n\n/** The reserved namespace of the forms wire. */\nexport const FORMS_RESERVED_PREFIX = 'elytra:'\n/** The page a submission came from. */\nexport const FORMS_SOURCE_KEY = 'elytra:source'\n/** The honeypot's value. */\nexport const FORMS_HONEYPOT_KEY = 'elytra:honeypot'\n/**\n * The hidden input a native (no-JS) form post names its form with — read by the `./next`\n * form-post route, never sent on: it becomes the ingest payload's `target`.\n */\nexport const FORMS_TARGET_FIELD = 'elytra:form'\n\n/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */\nexport const FORM_FIELD_NAME_MAX = 200\n\n/**\n * Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's\n * rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms\n * addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to\n * follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.\n */\nexport function formFieldNameProblem(name: string): string | undefined {\n const core = convexFieldNameProblem(name)\n if (core) return core\n if (name.length === 0) return 'is empty'\n if (name.length > FORM_FIELD_NAME_MAX) return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`\n return undefined\n}\n\n/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */\nexport function formFieldNameMessage(name: string, problem: string): string {\n return `The field name “${name}” ${problem}.`\n}\n/** The honeypot field both consumers' forms already render (overridable in the site helpers). */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/**\n * The file types a form upload may have — documents and raster images. Anything that a\n * browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is\n * refused by the receiver's handler (415), and so is every type not listed.\n */\nexport const FORMS_UPLOAD_CONTENT_TYPES: readonly string[] = [\n 'application/pdf',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.oasis.opendocument.text',\n 'application/rtf',\n 'text/plain',\n 'text/csv',\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/heic',\n 'image/heif',\n]\n\n/** The longest source URL a row keeps. */\nconst SOURCE_URL_MAX = 2000\n\n/** What a site's submission becomes in the row (the visitor's part — see the kind's header). */\nexport interface SubmissionColumns {\n form: string\n payload: SubmissionField[]\n sourceUrl?: string\n spam: SpamVerdict\n}\n\nexport type SubmissionRead =\n | { ok: true; columns: SubmissionColumns }\n | { ok: false; status: 422; message: string; issues: { path: (string | number)[]; message: string }[] }\n\nfunction isEmptyValue(value: FormValue): boolean {\n return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0)\n}\n\n/**\n * Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)\n * a value that is not a form value, an unknown reserved key, an over-long source URL, and a\n * submission with no answer — unless it carries files (`hasFiles`).\n */\nexport function readSubmissionPayload(\n payload: Extract<IngestPayload, { kind: 'submission' }>,\n options: { hasFiles?: boolean } = {},\n): SubmissionRead {\n const issues: { path: (string | number)[]; message: string }[] = []\n const fields: SubmissionField[] = []\n let sourceUrl: string | undefined\n let spam: SpamVerdict = 'clean'\n // Sorted by name: an object's key order does not survive a Convex function boundary\n // (measured — the receiver's handler receives the keys sorted), so the stored order is the\n // one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).\n for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {\n if (name === FORMS_SOURCE_KEY) {\n if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {\n issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` })\n } else if (raw.length > 0) sourceUrl = raw\n continue\n }\n if (name === FORMS_HONEYPOT_KEY) {\n if (typeof raw === 'string' && raw.trim().length > 0) spam = 'honeypot'\n continue\n }\n if (name.startsWith(FORMS_RESERVED_PREFIX)) {\n issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` })\n continue\n }\n const value = formValueSchema.safeParse(raw)\n if (!value.success) {\n issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` })\n continue\n }\n const nameProblem = formFieldNameProblem(name)\n if (nameProblem) {\n issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) })\n continue\n }\n if (!isEmptyValue(value.data)) fields.push({ name, value: value.data })\n }\n if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {\n issues.push({ path: ['data'], message: 'The submission carries no answer.' })\n }\n if (issues.length > 0) {\n return { ok: false, status: 422, message: 'The submission is not valid.', issues }\n }\n return {\n ok: true,\n columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },\n }\n}\n\n/** The display text of one value (a list joined by commas, a boolean as yes/no). */\nexport function formatFormValue(value: FormValue): string {\n if (Array.isArray(value)) return value.join(', ')\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n"]}
1
+ {"version":3,"file":"submission.js","sourceRoot":"","sources":["../../src/shared/submission.ts"],"names":[],"mappings":"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","sourcesContent":["import { convexFieldNameProblem } from '@elytracms/core/ingest'\nimport type { IngestPayload } from '@elytracms/core/ingest'\nimport { formValueSchema } from './kind'\nimport type { FormValue, SpamVerdict, SubmissionField } from './kind'\n\n/**\n * EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`\n * payload carries for this addon, and the ONE reader the receiver's handler turns it into a\n * row with. Pure (no Convex), so the rules are unit-tested on their own.\n *\n * `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,\n * in the `elytra:` namespace:\n *\n * - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);\n * - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).\n *\n * Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are\n * not answers and are not stored; a submission with no answer and no file is refused.\n *\n * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name\n * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no\n * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver\n * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored\n * payload's `name`). The site helpers refuse a name before sending, with the same reason the\n * envelope would give. There is no definition-time gate: a form's fields are CMS content\n * (`source.ts`), so a refused name surfaces on submission, on both site paths.\n */\n\n/** The reserved namespace of the forms wire. */\nexport const FORMS_RESERVED_PREFIX = 'elytra:'\n/** The page a submission came from. */\nexport const FORMS_SOURCE_KEY = 'elytra:source'\n/** The honeypot's value. */\nexport const FORMS_HONEYPOT_KEY = 'elytra:honeypot'\n/**\n * The hidden input a native (no-JS) form post names its form with — read by the `./next`\n * form-post route, never sent on: it becomes the ingest payload's `target`.\n */\nexport const FORMS_TARGET_FIELD = 'elytra:form'\n\n/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */\nexport const FORM_FIELD_NAME_MAX = 200\n\n/**\n * Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's\n * rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms\n * addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to\n * follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.\n */\nexport function formFieldNameProblem(name: string): string | undefined {\n const core = convexFieldNameProblem(name)\n if (core) return core\n if (name.length === 0) return 'is empty'\n if (name.length > FORM_FIELD_NAME_MAX) return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`\n return undefined\n}\n\n/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */\nexport function formFieldNameMessage(name: string, problem: string): string {\n return `The field name “${name}” ${problem}.`\n}\n/** The honeypot field both consumers' forms already render (overridable in the site helpers). */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/**\n * The file types a form upload may have — documents and raster images. Anything that a\n * browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is\n * refused by the receiver's handler (415), and so is every type not listed.\n */\nexport const FORMS_UPLOAD_CONTENT_TYPES: readonly string[] = [\n 'application/pdf',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.oasis.opendocument.text',\n 'application/rtf',\n 'text/plain',\n 'text/csv',\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/heic',\n 'image/heif',\n]\n\n/** The longest source URL a row keeps. */\nconst SOURCE_URL_MAX = 2000\n\n/** What a site's submission becomes in the row (the visitor's part — see the kind's header). */\nexport interface SubmissionColumns {\n form: string\n payload: SubmissionField[]\n sourceUrl?: string\n spam: SpamVerdict\n}\n\nexport type SubmissionRead =\n | { ok: true; columns: SubmissionColumns }\n | { ok: false; status: 422; message: string; issues: { path: (string | number)[]; message: string }[] }\n\nfunction isEmptyValue(value: FormValue): boolean {\n return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0)\n}\n\n/**\n * Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)\n * a value that is not a form value, an unknown reserved key, an over-long source URL, and a\n * submission with no answer — unless it carries files (`hasFiles`).\n */\nexport function readSubmissionPayload(\n payload: Extract<IngestPayload, { kind: 'submission' }>,\n options: { hasFiles?: boolean } = {},\n): SubmissionRead {\n const issues: { path: (string | number)[]; message: string }[] = []\n const fields: SubmissionField[] = []\n let sourceUrl: string | undefined\n let spam: SpamVerdict = 'clean'\n // Sorted by name: an object's key order does not survive a Convex function boundary\n // (measured — the receiver's handler receives the keys sorted), so the stored order is the\n // one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).\n for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {\n if (name === FORMS_SOURCE_KEY) {\n if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {\n issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` })\n } else if (raw.length > 0) sourceUrl = raw\n continue\n }\n if (name === FORMS_HONEYPOT_KEY) {\n if (typeof raw === 'string' && raw.trim().length > 0) spam = 'honeypot'\n continue\n }\n if (name.startsWith(FORMS_RESERVED_PREFIX)) {\n issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` })\n continue\n }\n const value = formValueSchema.safeParse(raw)\n if (!value.success) {\n issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` })\n continue\n }\n const nameProblem = formFieldNameProblem(name)\n if (nameProblem) {\n issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) })\n continue\n }\n if (!isEmptyValue(value.data)) fields.push({ name, value: value.data })\n }\n if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {\n issues.push({ path: ['data'], message: 'The submission carries no answer.' })\n }\n if (issues.length > 0) {\n return { ok: false, status: 422, message: 'The submission is not valid.', issues }\n }\n return {\n ok: true,\n columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },\n }\n}\n\n/** The display text of one value (a list joined by commas, a boolean as yes/no). */\nexport function formatFormValue(value: FormValue): string {\n if (Array.isArray(value)) return value.join(', ')\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n"]}
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.99",
4
+ "version": "0.0.101",
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.99"
31
+ "@elytracms/core": "^0.0.101"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "convex": "^1.42.1",
35
35
  "react": "^19.0.0",
36
- "@elytracms/next": "^0.0.99",
37
- "@elytracms/studio": "^0.0.99"
36
+ "@elytracms/next": "^0.0.101",
37
+ "@elytracms/studio": "^0.0.101"
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.99",
63
- "@elytracms/next": "^0.0.99"
62
+ "@elytracms/next": "^0.0.101",
63
+ "@elytracms/studio": "^0.0.101"
64
64
  },
65
65
  "files": [
66
66
  "dist",
@@ -4,8 +4,15 @@ import type { ConvexHttpClient } from 'convex/browser'
4
4
  import { makeFunctionReference } from 'convex/server'
5
5
  import type { FunctionReference } from 'convex/server'
6
6
  import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
7
- import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
7
+ import {
8
+ INGEST_RECEIVER_PATH,
9
+ INGEST_SIGNATURE_HEADER,
10
+ convexFieldNameProblem,
11
+ ingestUploadSha256,
12
+ signedIngestRequest,
13
+ } from '@elytracms/core/ingest'
8
14
  import type { IngestReceiverBody } from '@elytracms/core/ingest'
15
+ import { signRevalidationBody } from '@elytracms/core/revalidation'
9
16
  import { InMemoryRecordStore } from '@elytracms/core/persistence'
10
17
  import type { RecordRepository } from '@elytracms/core/persistence'
11
18
  import schema from '../../../studio/convex/schema'
@@ -517,6 +524,72 @@ describe('the site’s form post, end to end', () => {
517
524
  expect(refused.headers.get('location')).toBe('https://site.test/?form=invalid')
518
525
  expect(await rows(t)).toHaveLength(1)
519
526
  })
527
+
528
+ /**
529
+ * EC-704 (Codex r6 F35) — the worst row, end to end: a field named `__proto__` beside an
530
+ * ordinary answer. Before, the builder's assignment lost it and the post landed a row WITHOUT
531
+ * the answer (`?form=ok`); a hand-written JSON body lost it in the envelope's record parse at
532
+ * the route and at the receiver alike. Now each of the three refuses it visibly, naming it, and
533
+ * nothing is stored.
534
+ */
535
+ it('EC-704: a field named `__proto__` → ?form=invalid, the route’s and the receiver’s 400 name it, NO row; control: the answer alone lands', async () => {
536
+ const t = await backend()
537
+ const viaReceiver = (async (url: string, init: RequestInit) => t.fetch(new URL(url).pathname, init)) as unknown as typeof fetch
538
+ const ingest = createIngestRoute({ secret: SECRET, siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
539
+ const formPost = createFormPostRoute({ ingest })
540
+ const post = (fields: [string, string][]) => {
541
+ const form = new FormData()
542
+ form.set(FORMS_TARGET_FIELD, 'contact')
543
+ form.set('website', '')
544
+ for (const [name, value] of fields) form.append(name, value)
545
+ return formPost.POST(
546
+ new Request('https://site.test/api/form', {
547
+ method: 'POST',
548
+ headers: { origin: 'https://site.test', referer: 'https://site.test/contact' },
549
+ body: form,
550
+ }),
551
+ )
552
+ }
553
+ const issue = { path: ['data', '__proto__'], message: `The field name “__proto__” ${convexFieldNameProblem('__proto__')}.` }
554
+
555
+ // 1. The no-JS form post (the browser helper builds through the same builder).
556
+ const refused = await post([['message', 'Hello'], ['__proto__', 'Keep this answer']])
557
+ expect(refused.headers.get('location')).toBe('https://site.test/contact?form=invalid')
558
+ expect(await rows(t)).toEqual([])
559
+
560
+ // 2. The site's route, a JSON body carrying the key as text (JSON.parse defines it).
561
+ const raw = '{"kind":"submission","target":"contact","data":{"message":"Hello","__proto__":"Keep this answer"}}'
562
+ const routed = await ingest.POST(
563
+ new Request('https://site.test/api/ingest', { method: 'POST', headers: { origin: 'https://site.test', 'content-type': 'application/json' }, body: raw }),
564
+ )
565
+ expect(routed.status).toBe(400)
566
+ expect((await routed.json()).issues).toEqual([issue])
567
+
568
+ // 3. The receiver, the same body SIGNED (a sender past the route).
569
+ const received = await t.fetch(INGEST_RECEIVER_PATH, {
570
+ method: 'POST',
571
+ headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(raw, SECRET) },
572
+ body: raw,
573
+ })
574
+ expect(received.status).toBe(400)
575
+ expect((await received.json()).issues).toEqual([issue])
576
+ expect(await rows(t)).toEqual([])
577
+
578
+ // Controls: the answer alone lands — through the post, and as the same signed body without the key.
579
+ const accepted = await post([['message', 'Hello']])
580
+ expect(accepted.headers.get('location')).toBe('https://site.test/contact?form=ok')
581
+ const clean = '{"kind":"submission","target":"contact","data":{"message":"Hello again"}}'
582
+ const direct = await t.fetch(INGEST_RECEIVER_PATH, {
583
+ method: 'POST',
584
+ headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signRevalidationBody(clean, SECRET) },
585
+ body: clean,
586
+ })
587
+ expect(direct.status).toBe(200)
588
+ expect((await rows(t)).map((row) => row.payload).sort((a, b) => String(a[0]!.value).localeCompare(String(b[0]!.value)))).toEqual([
589
+ [{ name: 'message', value: 'Hello' }],
590
+ [{ name: 'message', value: 'Hello again' }],
591
+ ])
592
+ })
520
593
  })
521
594
 
522
595
  // --- The kind's contract in both adapters --------------------------------------------------------
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { CONVEX_MAX_NESTING_DEPTH } from '@elytracms/core/cms-core'
3
+ import { convexFieldNameProblem } from '@elytracms/core/ingest'
3
4
  import { recordRowDepth, resolveRecordSave } from '@elytracms/core/persistence'
4
5
  import { submissionRecordKind } from './kind'
5
- import { FORMS_HONEYPOT_KEY, FORMS_SOURCE_KEY, readSubmissionPayload } from './submission'
6
+ import { FORMS_HONEYPOT_KEY, FORMS_SOURCE_KEY, formFieldNameProblem, readSubmissionPayload } from './submission'
6
7
 
7
8
  /** EC-613 — the one reader of a site's submission payload (the receiver's handler runs it). */
8
9
 
@@ -58,6 +59,21 @@ describe('readSubmissionPayload', () => {
58
59
  expect(readSubmissionPayload(submission({ name: '' })).ok).toBe(false)
59
60
  expect(readSubmissionPayload(submission({ name: '' }), { hasFiles: true }).ok).toBe(true)
60
61
  })
62
+
63
+ it('EC-704: a field named `__proto__` is refused by the ONE name rule (422 naming it); a VALUE "__proto__" is content', () => {
64
+ expect(formFieldNameProblem('__proto__')).toBe(convexFieldNameProblem('__proto__'))
65
+ expect(formFieldNameProblem('__proto__')).toBeDefined()
66
+ // An own key, as a parsed request body carries it (JSON.parse DEFINES it).
67
+ const read = readSubmissionPayload(submission(JSON.parse('{"message":"Hello","__proto__":"Keep this answer"}') as Record<string, unknown>))
68
+ expect(read).toMatchObject({ ok: false, status: 422, issues: [{ path: ['data', '__proto__'] }] })
69
+ // Controls: the answer alone, and the string '__proto__' as an answer, are read.
70
+ expect(readSubmissionPayload(submission({ message: 'Hello' })).ok).toBe(true)
71
+ const value = readSubmissionPayload(submission({ message: '__proto__', topics: ['__proto__'] }))
72
+ expect(value.ok && value.columns.payload).toEqual([
73
+ { name: 'message', value: '__proto__' },
74
+ { name: 'topics', value: ['__proto__'] },
75
+ ])
76
+ })
61
77
  })
62
78
 
63
79
  describe('the submission kind', () => {
@@ -19,9 +19,11 @@ import type { FormValue, SpamVerdict, SubmissionField } from './kind'
19
19
  *
20
20
  * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
21
21
  * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
22
- * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
23
- * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
24
- * site helpers refuse a name before sending, with the same reason the envelope would give.
22
+ * leading `$`, not `__proto__` (EC-704), at most 1024 characters; the route and the receiver
23
+ * refuse anything else with 400) — plus the forms addon's own cap of 200 characters (the stored
24
+ * payload's `name`). The site helpers refuse a name before sending, with the same reason the
25
+ * envelope would give. There is no definition-time gate: a form's fields are CMS content
26
+ * (`source.ts`), so a refused name surfaces on submission, on both site paths.
25
27
  */
26
28
 
27
29
  /** The reserved namespace of the forms wire. */