@elytracms/forms 0.0.96 → 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.
- package/dist/next/submit.d.ts +8 -1
- package/dist/next/submit.js +16 -2
- package/dist/next/submit.js.map +1 -1
- package/package.json +6 -6
- package/src/convex/claim-ownership.convex.test.ts +270 -0
- package/src/convex/dev-ingest-runner.convex.test.ts +192 -0
- package/src/convex/forms.convex.test.ts +21 -11
- package/src/convex/index.ts +15 -11
- package/src/convex/ingest-causation.convex.test.ts +265 -0
package/dist/next/submit.d.ts
CHANGED
|
@@ -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;
|
package/dist/next/submit.js
CHANGED
|
@@ -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
|
|
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.');
|
package/dist/next/submit.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
37
|
-
"@elytracms/studio": "^0.0.
|
|
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/next": "^0.0.
|
|
63
|
-
"@elytracms/studio": "^0.0.
|
|
62
|
+
"@elytracms/next": "^0.0.98",
|
|
63
|
+
"@elytracms/studio": "^0.0.98"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
66
66
|
"dist",
|
|
@@ -0,0 +1,270 @@
|
|
|
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 { makeFunctionReference } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
|
|
7
|
+
import type { IngestReceiverBody } from '@elytracms/core/ingest'
|
|
8
|
+
import schema from '../../../studio/convex/schema'
|
|
9
|
+
import * as assetsModule from '../../../studio/convex/assets'
|
|
10
|
+
import * as deliveryModule from '../../../studio/convex/delivery'
|
|
11
|
+
import { defineStudioHttp } from '../../../studio/convex/http'
|
|
12
|
+
import { ConvexPersistenceAdapter } from '../../../studio/src/lib/persistence/convex-adapter'
|
|
13
|
+
import * 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-671 (Codex round 3 F20) — EC-304 Phase B's first EXECUTABLE probe: the ingest upload
|
|
21
|
+
* claim against storage ids a visitor does not own, through the real paths — the attacker's
|
|
22
|
+
* input harvested from the anonymous `delivery:snapshot`, a fresh intent of their own from
|
|
23
|
+
* the receiver, the forms addon's claim, its rollback and its removal. Every defect line
|
|
24
|
+
* says what the base (`3f9c636c`) did; the counterexample form (those lines asserted) passed
|
|
25
|
+
* 7/7 there before it was inverted. Run only inside convex-test, generated fixture ids, an
|
|
26
|
+
* unreachable `.invalid` delivery host; nothing is sent.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The status a claim naming a file stored BEFORE its intent gets: the ownership refusal (422),
|
|
31
|
+
* before the declaration is measured (the floor alone answered the mismatch's 413).
|
|
32
|
+
*/
|
|
33
|
+
const REFUSED_MISMATCHING = 422
|
|
34
|
+
|
|
35
|
+
const SECRET = 'ec671-ingest-secret'
|
|
36
|
+
const PROJECT_ID = 'forms-test'
|
|
37
|
+
const ADMIN = 'user_admin'
|
|
38
|
+
const T0 = '2026-10-28T12:00:00.000Z'
|
|
39
|
+
const at = (offsetMs: number) => new Date(Date.parse(T0) + offsetMs)
|
|
40
|
+
|
|
41
|
+
function env(): Record<string, string | undefined> {
|
|
42
|
+
return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.useFakeTimers()
|
|
47
|
+
vi.setSystemTime(new Date(T0))
|
|
48
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
49
|
+
env()[FORMS_DELIVERY_URL_ENV] = 'https://site.invalid/api/forms'
|
|
50
|
+
})
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.useRealTimers()
|
|
53
|
+
delete env()['ELYTRA_INGEST_SECRET']
|
|
54
|
+
delete env()[FORMS_DELIVERY_URL_ENV]
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const silentAuth = { addHttpRoutes: () => {} }
|
|
58
|
+
// The consumer's files this probe needs: the engine's `assets` (the editor's upload) and
|
|
59
|
+
// `delivery` (the attacker's harvest) re-exports, the addon's `forms`, and the `http.ts` line.
|
|
60
|
+
const modules = {
|
|
61
|
+
'/convex/_generated/api.ts': async () => ({}),
|
|
62
|
+
'/convex/assets.ts': async () => assetsModule,
|
|
63
|
+
'/convex/delivery.ts': async () => deliveryModule,
|
|
64
|
+
'/convex/forms.ts': async () => formsModule,
|
|
65
|
+
'/convex/http.ts': async () => ({
|
|
66
|
+
default: defineStudioHttp({ auth: silentAuth, ingest: { submission: formsModule.formsIngest } }),
|
|
67
|
+
}),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function backend() {
|
|
71
|
+
const t = convexTest(schema, modules)
|
|
72
|
+
await t.run(async (ctx) => {
|
|
73
|
+
await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: T0, updatedAt: T0 })
|
|
74
|
+
})
|
|
75
|
+
return t
|
|
76
|
+
}
|
|
77
|
+
type Backend = Awaited<ReturnType<typeof backend>>
|
|
78
|
+
|
|
79
|
+
const asAdmin = (t: Backend) => t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
|
|
80
|
+
const upsertAsset = makeFunctionReference<'mutation'>('assets:upsert')
|
|
81
|
+
const snapshot = makeFunctionReference<'query', { projectId: string }, { assets: { id: string; storageKey: string }[] }>(
|
|
82
|
+
'delivery:snapshot',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async function post(t: Backend, body: IngestReceiverBody) {
|
|
86
|
+
const signed = await signedIngestRequest(body, SECRET)
|
|
87
|
+
return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Bytes as an issued upload URL stores them (convex-test records size only; the real upload records the type). */
|
|
91
|
+
async function storeFile(t: Backend, bytes: number, contentType: string): Promise<string> {
|
|
92
|
+
return t.run(async (ctx) => {
|
|
93
|
+
const id = await ctx.storage.store(new Blob([new Uint8Array(bytes)], { type: contentType }))
|
|
94
|
+
await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
|
|
95
|
+
return id
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An editor's asset upload: the bytes stored, then the row written through `assets:upsert` (the real write path). */
|
|
100
|
+
async function publishAsset(t: Backend, assetId: string, bytes: number, contentType: string): Promise<string> {
|
|
101
|
+
const storageKey = await storeFile(t, bytes, contentType)
|
|
102
|
+
const stamp = new Date().toISOString()
|
|
103
|
+
await asAdmin(t).mutation(upsertAsset, {
|
|
104
|
+
assetId,
|
|
105
|
+
filename: `${assetId}.jpg`,
|
|
106
|
+
contentType,
|
|
107
|
+
byteSize: bytes,
|
|
108
|
+
storageKey,
|
|
109
|
+
createdAt: stamp,
|
|
110
|
+
updatedAt: stamp,
|
|
111
|
+
})
|
|
112
|
+
return storageKey
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The attacker's harvest: the anonymous public delivery snapshot names every asset's storage id. */
|
|
116
|
+
async function harvest(t: Backend, assetId: string): Promise<string> {
|
|
117
|
+
const { assets } = await t.query(snapshot, { projectId: PROJECT_ID })
|
|
118
|
+
const asset = assets.find((entry) => entry.id === assetId)
|
|
119
|
+
if (!asset) throw new Error(`asset ${assetId} not on the public snapshot`)
|
|
120
|
+
return asset.storageKey
|
|
121
|
+
}
|
|
122
|
+
|
|
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 })
|
|
133
|
+
expect(answer.status).toBe(200)
|
|
134
|
+
return ((await answer.json()) as { uploadId: string }).uploadId
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const SUBMISSION = {
|
|
138
|
+
kind: 'submission',
|
|
139
|
+
target: 'contact',
|
|
140
|
+
data: { email: 'mallory@example.test', message: 'hi', [FORMS_HONEYPOT_KEY]: '' },
|
|
141
|
+
} as const
|
|
142
|
+
|
|
143
|
+
async function rows(t: Backend): Promise<SubmissionRecord[]> {
|
|
144
|
+
return (await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'submission'))).map(
|
|
145
|
+
(row) => ({ ...(row.data as object), id: row.recordId }) as unknown as SubmissionRecord,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether stored bytes still exist (`storage.get` → a Blob, which is not a Convex value: answer a boolean). */
|
|
150
|
+
const survives = (t: Backend, storageId: string) => t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
|
|
151
|
+
|
|
152
|
+
function editor(t: Backend) {
|
|
153
|
+
const client = {
|
|
154
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin(t).query(ref, args ?? {}),
|
|
155
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin(t).mutation(ref, args ?? {}),
|
|
156
|
+
} as unknown as ConvexHttpClient
|
|
157
|
+
return new ConvexPersistenceAdapter(client, PROJECT_ID).records(submissionRecordKind)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** An asset an editor published a day before the attacker's intent, harvested from the public snapshot. */
|
|
161
|
+
async function harvestedOldAsset(t: Backend): Promise<string> {
|
|
162
|
+
vi.setSystemTime(at(-24 * 60 * 60 * 1000))
|
|
163
|
+
await publishAsset(t, 'hero', 400, 'image/jpeg')
|
|
164
|
+
vi.setSystemTime(new Date(T0))
|
|
165
|
+
return harvest(t, 'hero')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A refused submission whose second claim is a genuine mismatch — the shape that triggers the rollback. */
|
|
169
|
+
async function postWithRefusedSecondClaim(t: Backend, first: { uploadId: string; storageId: string }) {
|
|
170
|
+
const liar = await declare(t, 'image/png', 500, 10) // its own 10 bytes' digest: the mismatch is the type
|
|
171
|
+
const bad = await storeFile(t, 10, 'text/html')
|
|
172
|
+
return post(t, { ...SUBMISSION, uploads: [first, { uploadId: liar, storageId: bad }] })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe('EC-671 probe — a visitor names an asset’s storage id in an upload claim', () => {
|
|
176
|
+
it('control: the anonymous delivery snapshot names the asset’s storage id (the attacker’s input is public)', async () => {
|
|
177
|
+
const t = await backend()
|
|
178
|
+
vi.setSystemTime(at(-24 * 60 * 60 * 1000))
|
|
179
|
+
const storageKey = await publishAsset(t, 'hero', 400, 'image/jpeg')
|
|
180
|
+
vi.setSystemTime(new Date(T0))
|
|
181
|
+
expect(await harvest(t, 'hero')).toBe(storageKey)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('(1) mismatching declaration: the claim is refused and the asset’s file survives', async () => {
|
|
185
|
+
const t = await backend()
|
|
186
|
+
const victim = await harvestedOldAsset(t)
|
|
187
|
+
const uploadId = await declare(t, 'image/png', 10) // a fresh intent of the attacker's own, mismatching the asset
|
|
188
|
+
const answer = await post(t, { ...SUBMISSION, uploads: [{ uploadId, storageId: victim }] })
|
|
189
|
+
expect(answer.status).toBe(REFUSED_MISMATCHING)
|
|
190
|
+
expect(await rows(t)).toEqual([])
|
|
191
|
+
expect(await survives(t, victim)).toBe(true) // base: false — the claim deleted it (ingest.ts:256)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('(2a) matching declaration, rollback: the asset’s file is never claimed, so a refused later claim cannot release it', async () => {
|
|
195
|
+
const t = await backend()
|
|
196
|
+
const victim = await harvestedOldAsset(t)
|
|
197
|
+
const matching = await declare(t, 'image/jpeg', 400)
|
|
198
|
+
const answer = await postWithRefusedSecondClaim(t, { uploadId: matching, storageId: victim })
|
|
199
|
+
expect(answer.status).toBe(422) // base: 415 — the first claim passed, the second refused
|
|
200
|
+
expect(await rows(t)).toEqual([])
|
|
201
|
+
expect(await survives(t, victim)).toBe(true) // base: false — claimed, then released by the rollback
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('(2b) matching declaration, removal: the claim cannot attach the asset’s file; a row naming it by hand cannot delete it', async () => {
|
|
205
|
+
const t = await backend()
|
|
206
|
+
const victim = await harvestedOldAsset(t)
|
|
207
|
+
const matching = await declare(t, 'image/jpeg', 400)
|
|
208
|
+
const answer = await post(t, {
|
|
209
|
+
...SUBMISSION,
|
|
210
|
+
data: { ...SUBMISSION.data, cv: 'cv.jpg' },
|
|
211
|
+
uploads: [{ uploadId: matching, storageId: victim }],
|
|
212
|
+
})
|
|
213
|
+
expect(answer.status).toBe(422) // base: 200 — claimed and attached
|
|
214
|
+
expect(await rows(t)).toEqual([])
|
|
215
|
+
// The deletion site holds on its own: a submission whose `files` name the asset's id (an
|
|
216
|
+
// editor's write — the kind's `save` takes the column) is removed, and the asset's file stays.
|
|
217
|
+
const repo = editor(t)
|
|
218
|
+
await repo.save(PROJECT_ID, {
|
|
219
|
+
id: 'submission_by_hand',
|
|
220
|
+
form: 'contact',
|
|
221
|
+
payload: [],
|
|
222
|
+
files: [{ field: 'cv', storageId: victim, contentType: 'image/jpeg', size: 400 }],
|
|
223
|
+
status: 'new',
|
|
224
|
+
spam: 'clean',
|
|
225
|
+
deliveryState: 'skipped',
|
|
226
|
+
submittedAt: T0,
|
|
227
|
+
})
|
|
228
|
+
expect((await rows(t)).map((row) => row.id)).toEqual(['submission_by_hand'])
|
|
229
|
+
await repo.remove(PROJECT_ID, 'submission_by_hand')
|
|
230
|
+
expect(await rows(t)).toEqual([])
|
|
231
|
+
expect(await survives(t, victim)).toBe(true) // base: false — removal deletes whatever `files` names
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('(3) fresh asset (critic): uploaded AFTER the intent, a matching declaration, a refused second claim → it survives', async () => {
|
|
235
|
+
const t = await backend()
|
|
236
|
+
const matching = await declare(t, 'image/jpeg', 400) // the attacker holds a fresh intent open…
|
|
237
|
+
vi.setSystemTime(at(60 * 1000))
|
|
238
|
+
await publishAsset(t, 'hero', 400, 'image/jpeg') // …an editor uploads an asset a minute later…
|
|
239
|
+
const victim = await harvest(t, 'hero') // …which is public at once
|
|
240
|
+
const answer = await postWithRefusedSecondClaim(t, { uploadId: matching, storageId: victim })
|
|
241
|
+
expect(answer.status).toBe(422) // base: 415 — the first claim passed, the second refused
|
|
242
|
+
expect(await rows(t)).toEqual([])
|
|
243
|
+
expect(await survives(t, victim)).toBe(true) // base: false — claimed, then released by the rollback
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('control: a genuine upload claims and attaches; removing its submission deletes its file (the instrument sees a deletion)', async () => {
|
|
247
|
+
const t = await backend()
|
|
248
|
+
const uploadId = await declare(t, 'application/pdf', 900, 800)
|
|
249
|
+
const own = await storeFile(t, 800, 'application/pdf')
|
|
250
|
+
const answer = await post(t, { ...SUBMISSION, data: { ...SUBMISSION.data, cv: 'cv.pdf' }, uploads: [{ uploadId, storageId: own }] })
|
|
251
|
+
expect(answer.status).toBe(200)
|
|
252
|
+
const [row] = await rows(t)
|
|
253
|
+
expect(row!.files).toEqual([
|
|
254
|
+
{ field: 'cv', name: 'cv.pdf', storageId: own, contentType: 'application/pdf', size: 800, url: expect.any(String) },
|
|
255
|
+
])
|
|
256
|
+
expect(await survives(t, own)).toBe(true)
|
|
257
|
+
await editor(t).remove(PROJECT_ID, row!.id)
|
|
258
|
+
expect(await survives(t, own)).toBe(false)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('control: a genuine mismatch is refused (no row); a genuine claim before it is released by the rollback', async () => {
|
|
262
|
+
const t = await backend()
|
|
263
|
+
const good = await declare(t, 'image/png', 500, 400)
|
|
264
|
+
const goodFile = await storeFile(t, 400, 'image/png')
|
|
265
|
+
const answer = await postWithRefusedSecondClaim(t, { uploadId: good, storageId: goodFile })
|
|
266
|
+
expect(answer.status).toBe(415)
|
|
267
|
+
expect(await rows(t)).toEqual([])
|
|
268
|
+
expect(await survives(t, goodFile)).toBe(false)
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -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
|
-
|
|
376
|
-
|
|
375
|
+
/** An upload intent declaring the file's type, size and (EC-685) the SHA-256 of the zero-filled `bytesOf` bytes the test stores. */
|
|
376
|
+
async function declareUpload(t: Backend, contentType: string, size: number, field = 'cv', bytesOf = size) {
|
|
377
|
+
const digest = await ingestUploadSha256(new Uint8Array(bytesOf))
|
|
378
|
+
if (!digest.ok) throw new Error(digest.message)
|
|
379
|
+
const answer = await post(t, { kind: 'upload', target: 'contact', field, contentType, size, sha256: digest.sha256 })
|
|
377
380
|
return { status: answer.status, body: (await answer.json()) as { uploadId?: string; uploadUrl?: string } }
|
|
378
381
|
}
|
|
379
382
|
|
|
@@ -381,7 +384,7 @@ describe('uploads, claimed by the submission', () => {
|
|
|
381
384
|
it('a claimed file is on the row (name, type, size, URL), its URL reaches the mail; removing the row deletes it', async () => {
|
|
382
385
|
const t = await backend()
|
|
383
386
|
const { mails } = site()
|
|
384
|
-
const { status, body } = await declareUpload(t, 'application/pdf', 900)
|
|
387
|
+
const { status, body } = await declareUpload(t, 'application/pdf', 900, 'cv', 800)
|
|
385
388
|
expect(status).toBe(200)
|
|
386
389
|
expect(typeof body.uploadUrl).toBe('string')
|
|
387
390
|
const storageId = await storeFile(t, 800, 'application/pdf')
|
|
@@ -406,12 +409,12 @@ describe('uploads, claimed by the submission', () => {
|
|
|
406
409
|
expect(await t.run((ctx) => ctx.storage.get(storageId as never))).toBeNull()
|
|
407
410
|
})
|
|
408
411
|
|
|
409
|
-
it('a claim that fails refuses the submission AND deletes the files it had already claimed', async () => {
|
|
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,
|
|
@@ -423,7 +426,8 @@ describe('uploads, claimed by the submission', () => {
|
|
|
423
426
|
expect(answer.status).toBe(415)
|
|
424
427
|
expect(await rows(t)).toEqual([])
|
|
425
428
|
expect(await t.run((ctx) => ctx.storage.get(goodFile as never))).toBeNull() // claimed, then released
|
|
426
|
-
|
|
429
|
+
// EC-671: the refused claim never deletes the id it names (the visitor's word, not an ownership fact).
|
|
430
|
+
expect(await t.run(async (ctx) => (await ctx.storage.get(wrongType as never)) !== null)).toBe(true)
|
|
427
431
|
expect(await scheduled(t)).toEqual([])
|
|
428
432
|
})
|
|
429
433
|
|
|
@@ -456,10 +460,16 @@ describe('the site’s form post, end to end', () => {
|
|
|
456
460
|
const t = await backend()
|
|
457
461
|
const viaReceiver = (async (url: string, init: RequestInit) => t.fetch(new URL(url).pathname, init)) as unknown as typeof fetch
|
|
458
462
|
const ingest = createIngestRoute({ secret: SECRET, siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
|
|
459
|
-
// The bytes' destination: the issued storage URL
|
|
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).
|
|
460
465
|
const storeBytes = (async (_url: string, init: RequestInit) => {
|
|
461
466
|
const blob = init.body as Blob
|
|
462
|
-
const
|
|
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
|
+
})
|
|
463
473
|
return Response.json({ storageId })
|
|
464
474
|
}) as unknown as typeof fetch
|
|
465
475
|
const formPost = createFormPostRoute({ ingest, fetchImpl: storeBytes })
|
|
@@ -469,7 +479,7 @@ describe('the site’s form post, end to end', () => {
|
|
|
469
479
|
form.set('email', 'ada@example.test')
|
|
470
480
|
form.set('message', 'Please call back.')
|
|
471
481
|
form.set('website', '')
|
|
472
|
-
form.set('cv', new File([new
|
|
482
|
+
form.set('cv', new File([new TextEncoder().encode('%PDF-1.7 Ada Lovelace, CV '.padEnd(300, '.'))], 'cv.pdf', { type: 'application/pdf' }))
|
|
473
483
|
const answer = await formPost.POST(
|
|
474
484
|
new Request('https://site.test/api/form', {
|
|
475
485
|
method: 'POST',
|
package/src/convex/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
ingestUploadRecordKind,
|
|
17
17
|
parseIngestPayload,
|
|
18
18
|
recordIngestUpload,
|
|
19
|
+
releaseIngestUploads,
|
|
19
20
|
} from '@elytracms/studio/convex/ingest'
|
|
20
21
|
import {
|
|
21
22
|
defineRecordFunctions,
|
|
@@ -60,7 +61,9 @@ import type { FormDeliveryRequest } from '../shared/delivery'
|
|
|
60
61
|
* under the forms upload policy and its intent recorded; a `submission` is read
|
|
61
62
|
* (`readSubmissionPayload`), its uploads claimed, the row written and its delivery
|
|
62
63
|
* scheduled. A claim that fails refuses the submission and deletes the files already
|
|
63
|
-
* claimed for it — no claimed bytes ever sit without a row
|
|
64
|
+
* claimed for it — no claimed bytes ever sit without a row. Every deletion goes through
|
|
65
|
+
* the engine's `releaseIngestUploads`, which deletes only bytes a claim bound to its
|
|
66
|
+
* intent (EC-671): an id a visitor or an editor merely NAMES is never deleted;
|
|
64
67
|
* - **`deliver`** — the scheduled action per row: it asks the site's delivery route to send
|
|
65
68
|
* (`attemptFormDelivery`) and `recordDelivery` logs the answer per recipient and schedules
|
|
66
69
|
* the next attempt (`applyDeliveryOutcome`);
|
|
@@ -130,24 +133,24 @@ export const save = mutation({
|
|
|
130
133
|
},
|
|
131
134
|
})
|
|
132
135
|
|
|
133
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Delete a submission AND the files it claimed (no claimed bytes outlive their row). EC-671:
|
|
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 — and,
|
|
140
|
+
* EC-685, keeps a claimed file an editor has since made an asset of (the asset owns it now).
|
|
141
|
+
*/
|
|
134
142
|
export const remove = mutation({
|
|
135
143
|
args: { [kind.idKey]: v.string() },
|
|
136
144
|
handler: async (ctx, args: Record<string, unknown>) => {
|
|
137
145
|
await requireCapability(ctx, kind.access.write)
|
|
138
146
|
const doc = await findRecordDoc(ctx, kind, args[kind.idKey] as string)
|
|
139
147
|
if (!doc) return
|
|
140
|
-
await
|
|
148
|
+
await releaseIngestUploads(ctx, storageIds((doc.data['files'] as SubmissionFile[] | undefined) ?? []))
|
|
141
149
|
await ctx.db.delete(doc._id)
|
|
142
150
|
},
|
|
143
151
|
})
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
for (const file of files) {
|
|
147
|
-
const id = file.storageId as GenericId<'_storage'>
|
|
148
|
-
if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
153
|
+
const storageIds = (files: readonly { storageId: string }[]) => files.map((file) => file.storageId)
|
|
151
154
|
|
|
152
155
|
// --- The receiver's handler ------------------------------------------------------------------
|
|
153
156
|
|
|
@@ -172,13 +175,14 @@ export const ingest = internalMutation({
|
|
|
172
175
|
const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
|
|
173
176
|
if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
|
|
174
177
|
|
|
175
|
-
// Claim every upload; on the first refusal,
|
|
178
|
+
// Claim every upload; on the first refusal, release what this submission already claimed
|
|
179
|
+
// (bound in this transaction — EC-671: nothing else is ever deleted here).
|
|
176
180
|
const files: SubmissionFile[] = []
|
|
177
181
|
const namesUsed = new Map<string, number>()
|
|
178
182
|
for (const claim of body.uploads ?? []) {
|
|
179
183
|
const claimed = await claimIngestUpload(ctx, claim, body.target, receivedAt)
|
|
180
184
|
if (!claimed.ok) {
|
|
181
|
-
await
|
|
185
|
+
await releaseIngestUploads(ctx, storageIds(files))
|
|
182
186
|
return claimed
|
|
183
187
|
}
|
|
184
188
|
const index = namesUsed.get(claimed.field) ?? 0
|
|
@@ -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
|
+
})
|