@elytracms/forms 0.0.97 → 0.0.98

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"]}
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.98",
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.98"
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.98",
37
+ "@elytracms/studio": "^0.0.98"
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/next": "^0.0.98",
63
+ "@elytracms/studio": "^0.0.98"
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)
@@ -252,7 +260,7 @@ describe('EC-671 probe — a visitor names an asset’s storage id in an upload
252
260
 
253
261
  it('control: a genuine mismatch is refused (no row); a genuine claim before it is released by the rollback', async () => {
254
262
  const t = await backend()
255
- const good = await declare(t, 'image/png', 500)
263
+ const good = await declare(t, 'image/png', 500, 400)
256
264
  const goodFile = await storeFile(t, 400, 'image/png')
257
265
  const answer = await postWithRefusedSecondClaim(t, { uploadId: good, storageId: goodFile })
258
266
  expect(answer.status).toBe(415)
@@ -0,0 +1,192 @@
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'])
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('negative control: signed under ANOTHER secret, every scenario fails visibly (the receiver refuses it)', async () => {
177
+ const core = await runner()
178
+ for (const scenario of core.INGEST_SCENARIOS) {
179
+ const t = await backend()
180
+ const lines: string[] = []
181
+ const result = await core.runIngestScenario(scenario, port(core, t, 'not-the-deployment-secret'), {
182
+ say: (line) => lines.push(line),
183
+ wire,
184
+ runId: 'r',
185
+ now: () => T0,
186
+ })
187
+ expect(result.ok, scenario.name).toBe(false)
188
+ expect(lines.some((line) => /✗ .*: 401 \(expected 200\)/.test(line)), scenario.name).toBe(true)
189
+ expect(await leftovers(t)).toMatchObject({ submissions: 0, assets: 0 })
190
+ }
191
+ })
192
+ })
@@ -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')
@@ -409,9 +412,9 @@ describe('uploads, claimed by the submission', () => {
409
412
  it('a claim that fails refuses the submission AND deletes the files it had already claimed (never the refused one)', 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,
@@ -457,10 +460,16 @@ describe('the site’s form post, end to end', () => {
457
460
  const t = await backend()
458
461
  const viaReceiver = (async (url: string, init: RequestInit) => t.fetch(new URL(url).pathname, init)) as unknown as typeof fetch
459
462
  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).
463
+ // The bytes' destination: the issued storage URL — convex-test stores THE POSTED BYTES, so
464
+ // the digest the deployment computes is of what the form sent (EC-685: the form hashed them).
461
465
  const storeBytes = (async (_url: string, init: RequestInit) => {
462
466
  const blob = init.body as Blob
463
- const storageId = await storeFile(t, blob.size, String(new Headers(init.headers).get('content-type')))
467
+ const contentType = String(new Headers(init.headers).get('content-type'))
468
+ const storageId = await t.run(async (ctx) => {
469
+ const id = await ctx.storage.store(new Blob([await blob.arrayBuffer()], { type: contentType }))
470
+ await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
471
+ return id
472
+ })
464
473
  return Response.json({ storageId })
465
474
  }) as unknown as typeof fetch
466
475
  const formPost = createFormPostRoute({ ingest, fetchImpl: storeBytes })
@@ -470,7 +479,7 @@ describe('the site’s form post, end to end', () => {
470
479
  form.set('email', 'ada@example.test')
471
480
  form.set('message', 'Please call back.')
472
481
  form.set('website', '')
473
- form.set('cv', new File([new Uint8Array(300)], 'cv.pdf', { type: 'application/pdf' }))
482
+ form.set('cv', new File([new TextEncoder().encode('%PDF-1.7 Ada Lovelace, CV '.padEnd(300, '.'))], 'cv.pdf', { type: 'application/pdf' }))
474
483
  const answer = await formPost.POST(
475
484
  new Request('https://site.test/api/form', {
476
485
  method: 'POST',
@@ -136,7 +136,8 @@ export const save = mutation({
136
136
  /**
137
137
  * Delete a submission AND the files it claimed (no claimed bytes outlive their row). EC-671:
138
138
  * 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.
139
+ * naming anything else (an editor's write can put any id there) is kept, never deleted — and,
140
+ * EC-685, keeps a claimed file an editor has since made an asset of (the asset owns it now).
140
141
  */
141
142
  export const remove = mutation({
142
143
  args: { [kind.idKey]: v.string() },
@@ -0,0 +1,265 @@
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, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
6
+ import type { IngestReceiverBody } from '@elytracms/core/ingest'
7
+ import schema from '../../../studio/convex/schema'
8
+ import * as assetsModule from '../../../studio/convex/assets'
9
+ import * as deliveryModule from '../../../studio/convex/delivery'
10
+ import { defineStudioHttp } from '../../../studio/convex/http'
11
+ import { ConvexPersistenceAdapter } from '../../../studio/src/lib/persistence/convex-adapter'
12
+ import { deleteAsset } from '../../../studio/src/lib/assets/delete-asset'
13
+ import * as formsModule from './index'
14
+ import { submissionRecordKind } from '../shared/kind'
15
+ import type { SubmissionRecord } from '../shared/kind'
16
+ import { FORMS_DELIVERY_URL_ENV } from '../shared/delivery'
17
+ import { FORMS_HONEYPOT_KEY } from '../shared/submission'
18
+
19
+ /**
20
+ * EC-685 (Codex round 4 F20 residual) — EC-304 Phase B's next executable probes: ownership
21
+ * that is CAUSAL (the claim binds only bytes its own intent stored) and that STAYS true when
22
+ * a delete spends it. Both orderings exactly as Codex wrote them, through the real paths —
23
+ * the signed receiver, the forms claim and removal, the real `assets:upsert` and the studio's
24
+ * own `deleteAsset` (bytes first, through `assets:deleteBlob`). Every defect line says what
25
+ * the base (`6310b7b4`) did. Run only inside convex-test, generated fixture ids, an
26
+ * unreachable `.invalid` delivery host; nothing is sent.
27
+ */
28
+
29
+ const SECRET = 'ec685-ingest-secret'
30
+ const PROJECT_ID = 'forms-test'
31
+ const ADMIN = 'user_admin'
32
+ const T0 = '2026-10-28T12:00:00.000Z'
33
+
34
+ function env(): Record<string, string | undefined> {
35
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
36
+ }
37
+
38
+ beforeEach(() => {
39
+ vi.useFakeTimers()
40
+ vi.setSystemTime(new Date(T0))
41
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
42
+ env()[FORMS_DELIVERY_URL_ENV] = 'https://site.invalid/api/forms'
43
+ })
44
+ afterEach(() => {
45
+ vi.useRealTimers()
46
+ delete env()['ELYTRA_INGEST_SECRET']
47
+ delete env()[FORMS_DELIVERY_URL_ENV]
48
+ })
49
+
50
+ const silentAuth = { addHttpRoutes: () => {} }
51
+ const modules = {
52
+ '/convex/_generated/api.ts': async () => ({}),
53
+ '/convex/assets.ts': async () => assetsModule,
54
+ '/convex/delivery.ts': async () => deliveryModule,
55
+ '/convex/forms.ts': async () => formsModule,
56
+ '/convex/http.ts': async () => ({
57
+ default: defineStudioHttp({ auth: silentAuth, ingest: { submission: formsModule.formsIngest } }),
58
+ }),
59
+ }
60
+
61
+ async function backend() {
62
+ const t = convexTest(schema, modules)
63
+ await t.run(async (ctx) => {
64
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: T0, updatedAt: T0 })
65
+ })
66
+ return t
67
+ }
68
+ type Backend = Awaited<ReturnType<typeof backend>>
69
+
70
+ const asAdmin = (t: Backend) => t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
71
+
72
+ /** The studio's own adapter over the deployment, signed in as an editor (the admin). */
73
+ function studio(t: Backend) {
74
+ const client = {
75
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin(t).query(ref, args ?? {}),
76
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin(t).mutation(ref, args ?? {}),
77
+ } as unknown as ConvexHttpClient
78
+ return new ConvexPersistenceAdapter(client, PROJECT_ID)
79
+ }
80
+
81
+ async function post(t: Backend, body: IngestReceiverBody) {
82
+ const signed = await signedIngestRequest(body, SECRET)
83
+ return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
84
+ }
85
+
86
+ /** File content: distinct bytes per label (a victim's CV is not predictable by an attacker). */
87
+ const content = (label: string, size: number) => {
88
+ const bytes = new Uint8Array(size)
89
+ const seed = new TextEncoder().encode(label)
90
+ for (let index = 0; index < size; index += 1) bytes[index] = seed[index % seed.length]! ^ (index & 0xff)
91
+ return bytes
92
+ }
93
+
94
+ /** Bytes as an issued upload URL stores them (convex-test records size only; the real upload records the type). */
95
+ async function storeFile(t: Backend, bytes: Uint8Array<ArrayBuffer>, contentType: string): Promise<string> {
96
+ return t.run(async (ctx) => {
97
+ const id = await ctx.storage.store(new Blob([bytes], { type: contentType }))
98
+ await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
99
+ return id
100
+ })
101
+ }
102
+
103
+ /** The digest an uploader declares for `bytes` (the one encoder, `@elytracms/core/ingest`). */
104
+ async function sha256Of(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
105
+ const digest = await ingestUploadSha256(bytes)
106
+ if (!digest.ok) throw new Error(digest.message)
107
+ return digest.sha256
108
+ }
109
+
110
+ /** An upload intent from the receiver, declaring the file the uploader holds (`bytes`): its type, size and SHA-256. */
111
+ async function declare(t: Backend, bytes: Uint8Array<ArrayBuffer>, contentType: string, size = bytes.byteLength): Promise<string> {
112
+ const answer = await post(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size, sha256: await sha256Of(bytes) })
113
+ expect(answer.status).toBe(200)
114
+ return ((await answer.json()) as { uploadId: string }).uploadId
115
+ }
116
+
117
+ const SUBMISSION = {
118
+ kind: 'submission',
119
+ target: 'contact',
120
+ data: { email: 'ec685@example.test', message: 'hi', cv: 'cv.pdf', [FORMS_HONEYPOT_KEY]: '' },
121
+ } as const
122
+
123
+ const claim = (t: Backend, uploadId: string, storageId: string) => post(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
124
+
125
+ async function rows(t: Backend): Promise<SubmissionRecord[]> {
126
+ return (await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'submission'))).map(
127
+ (row) => ({ ...(row.data as object), id: row.recordId }) as unknown as SubmissionRecord,
128
+ )
129
+ }
130
+
131
+ const bindings = (t: Backend) =>
132
+ t.run(async (ctx) =>
133
+ (await ctx.db.query('records').collect())
134
+ .filter((row) => row.kind === 'ingestBinding')
135
+ .map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
136
+ )
137
+
138
+ /** Whether stored bytes still exist (`storage.get` → a Blob, which is not a Convex value: answer a boolean). */
139
+ const survives = (t: Backend, storageId: string) => t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
140
+
141
+ /** A genuine upload, claimed and bound: its own intent, its own bytes. Returns the row and the file. */
142
+ async function genuineSubmission(t: Backend, label: string): Promise<{ rowId: string; file: string }> {
143
+ const bytes = content(label, 800)
144
+ const uploadId = await declare(t, bytes, 'application/pdf')
145
+ const file = await storeFile(t, bytes, 'application/pdf')
146
+ const answer = await claim(t, uploadId, file)
147
+ expect(answer.status).toBe(200)
148
+ return { rowId: ((await answer.json()) as { id: string }).id, file }
149
+ }
150
+
151
+ /** An editor promotes the submission's file to an asset — `assets:upsert` over the bound storage id (a legitimate edit). */
152
+ async function promote(t: Backend, assetId: string, file: string): Promise<void> {
153
+ await studio(t).assets.upsertAsset(PROJECT_ID, {
154
+ id: assetId,
155
+ filename: `${assetId}.pdf`,
156
+ contentType: 'application/pdf',
157
+ byteSize: 800,
158
+ storageKey: file,
159
+ url: `https://assets.invalid/${assetId}.pdf`,
160
+ })
161
+ }
162
+
163
+ /** The studio's own asset delete (bytes first, through `assets:deleteBlob`; then the row). */
164
+ function deleteAssetInStudio(t: Backend, assetId: string) {
165
+ const adapter = studio(t)
166
+ return deleteAsset(
167
+ { assets: adapter.assets, blobs: adapter.blobs, references: { inbound: async () => [] } },
168
+ PROJECT_ID,
169
+ assetId,
170
+ { confirmInUse: true },
171
+ )
172
+ }
173
+
174
+ const assetIds = (t: Backend) => t.run(async (ctx) => (await ctx.db.query('assets').collect()).map((row) => row.assetId))
175
+
176
+ describe('EC-685 probe (1) — causation: a claim binds only bytes its own intent stored', () => {
177
+ it('intents A and B issued; bytes stored through B’s URL: A’s claim naming them is refused, B’s claim binds them', async () => {
178
+ const t = await backend()
179
+ const victim = content('B: the victim’s own CV', 800)
180
+ const attacker = content('A: whatever the attacker holds', 800)
181
+ const a = await declare(t, attacker, 'application/pdf') // A's facts match B's file in type and size…
182
+ const b = await declare(t, victim, 'application/pdf')
183
+ const file = await storeFile(t, victim, 'application/pdf') // …and the bytes go through B's URL, after both intents
184
+ const byA = await claim(t, a, file)
185
+ expect(byA.status).toBe(422) // base: 200 — A bound B's file (eligibility checks only: after A, unbound, no asset's)
186
+ expect(await bindings(t)).toEqual([])
187
+ const byB = await claim(t, b, file)
188
+ expect(byB.status).toBe(200) // base: 422 — the file was A's binding already
189
+ expect(await bindings(t)).toEqual([{ storageId: file, uploadId: b }])
190
+ expect((await rows(t)).map((row) => row.files?.[0]?.storageId)).toEqual([file])
191
+ })
192
+
193
+ it('the claim reads the deployment’s stored digest by its form: convex-test stores BASE64 (measured), the intent declares hex', async () => {
194
+ const t = await backend()
195
+ const bytes = content('a CV', 800)
196
+ const file = await storeFile(t, bytes, 'application/pdf')
197
+ const stored = await t.run(async (ctx) => ((await ctx.db.system.get(file as never)) as { sha256: string }).sha256)
198
+ expect(stored).toMatch(/^[A-Za-z0-9+/]{43}=$/) // convex-test 0.0.54 `blobSha`: btoa(…)
199
+ expect(await sha256Of(bytes)).toMatch(/^[0-9a-f]{64}$/)
200
+ })
201
+
202
+ it('RESIDUAL (named, not closed): bytes an attacker can PREDICT can be declared — A binds them first', async () => {
203
+ // Causal only for unpredictable bytes: a deterministic file (one an addon generates, a
204
+ // well-known document) is known before it exists, so its digest can be declared by anyone.
205
+ const t = await backend()
206
+ const predictable = content('a template every visitor uploads', 800)
207
+ const a = await declare(t, predictable, 'application/pdf')
208
+ const b = await declare(t, predictable, 'application/pdf')
209
+ const file = await storeFile(t, predictable, 'application/pdf')
210
+ expect((await claim(t, a, file)).status).toBe(200)
211
+ expect((await claim(t, b, file)).status).toBe(422) // bound already (check 2)
212
+ expect(await bindings(t)).toEqual([{ storageId: file, uploadId: a }])
213
+ })
214
+
215
+ it('control: a genuine claim binds (its own intent, its own bytes)', async () => {
216
+ const t = await backend()
217
+ const { file } = await genuineSubmission(t, 'a genuine CV')
218
+ expect((await bindings(t)).map((binding) => binding.storageId)).toEqual([file])
219
+ })
220
+ })
221
+
222
+ describe('EC-685 probe (2) — ownership stays true when a delete spends it', () => {
223
+ it('a claimed, bound file; an asset is upserted over it; the submission is removed: the asset’s file survives', async () => {
224
+ const t = await backend()
225
+ const { rowId, file } = await genuineSubmission(t, 'a CV the editor keeps')
226
+ await promote(t, 'promoted-cv', file)
227
+ await studio(t).records(submissionRecordKind).remove(PROJECT_ID, rowId)
228
+ expect(await rows(t)).toEqual([])
229
+ expect(await survives(t, file)).toBe(true) // base: false — release deleted a file an asset now references
230
+ expect(await bindings(t)).toEqual([]) // ownership passed to the asset: the binding is spent
231
+ // …and the asset owns it now: deleting the asset deletes the file.
232
+ expect(await deleteAssetInStudio(t, 'promoted-cv')).toEqual({ outcome: 'deleted' })
233
+ expect(await survives(t, file)).toBe(false)
234
+ })
235
+
236
+ it('mirror: the asset is deleted while the binding holds: the submission’s file survives, the asset row goes', async () => {
237
+ const t = await backend()
238
+ const { rowId, file } = await genuineSubmission(t, 'a CV the editor un-promotes')
239
+ await promote(t, 'promoted-cv', file)
240
+ expect(await deleteAssetInStudio(t, 'promoted-cv')).toEqual({ outcome: 'deleted' })
241
+ expect(await assetIds(t)).toEqual([])
242
+ expect(await survives(t, file)).toBe(true) // base: false — deleteBlob deleted bytes an ingest binding holds
243
+ // …and the submission still owns it: removing the submission deletes the file.
244
+ await studio(t).records(submissionRecordKind).remove(PROJECT_ID, rowId)
245
+ expect(await survives(t, file)).toBe(false)
246
+ expect(await bindings(t)).toEqual([])
247
+ })
248
+
249
+ it('control: a genuine release of an unreferenced bound file deletes it (the instrument sees a deletion)', async () => {
250
+ const t = await backend()
251
+ const { rowId, file } = await genuineSubmission(t, 'a CV nobody promoted')
252
+ expect(await survives(t, file)).toBe(true)
253
+ await studio(t).records(submissionRecordKind).remove(PROJECT_ID, rowId)
254
+ expect(await survives(t, file)).toBe(false)
255
+ expect(await bindings(t)).toEqual([])
256
+ })
257
+
258
+ it('control: an asset nobody bound is deleted with its bytes (the instrument sees deleteBlob delete)', async () => {
259
+ const t = await backend()
260
+ const file = await storeFile(t, content('an editor’s own upload', 800), 'application/pdf')
261
+ await promote(t, 'own-asset', file)
262
+ expect(await deleteAssetInStudio(t, 'own-asset')).toEqual({ outcome: 'deleted' })
263
+ expect(await survives(t, file)).toBe(false)
264
+ })
265
+ })