@elytracms/forms 0.0.95 → 0.0.97

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/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.95",
4
+ "version": "0.0.97",
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.95"
31
+ "@elytracms/core": "^0.0.97"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "convex": "^1.42.1",
35
35
  "react": "^19.0.0",
36
- "@elytracms/next": "^0.0.95",
37
- "@elytracms/studio": "^0.0.95"
36
+ "@elytracms/next": "^0.0.97",
37
+ "@elytracms/studio": "^0.0.97"
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.95",
63
- "@elytracms/next": "^0.0.95"
62
+ "@elytracms/studio": "^0.0.97",
63
+ "@elytracms/next": "^0.0.97"
64
64
  },
65
65
  "files": [
66
66
  "dist",
@@ -0,0 +1,262 @@
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, 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
+ 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 })
125
+ expect(answer.status).toBe(200)
126
+ return ((await answer.json()) as { uploadId: string }).uploadId
127
+ }
128
+
129
+ const SUBMISSION = {
130
+ kind: 'submission',
131
+ target: 'contact',
132
+ data: { email: 'mallory@example.test', message: 'hi', [FORMS_HONEYPOT_KEY]: '' },
133
+ } as const
134
+
135
+ async function rows(t: Backend): Promise<SubmissionRecord[]> {
136
+ return (await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'submission'))).map(
137
+ (row) => ({ ...(row.data as object), id: row.recordId }) as unknown as SubmissionRecord,
138
+ )
139
+ }
140
+
141
+ /** Whether stored bytes still exist (`storage.get` → a Blob, which is not a Convex value: answer a boolean). */
142
+ const survives = (t: Backend, storageId: string) => t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
143
+
144
+ function editor(t: Backend) {
145
+ const client = {
146
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin(t).query(ref, args ?? {}),
147
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin(t).mutation(ref, args ?? {}),
148
+ } as unknown as ConvexHttpClient
149
+ return new ConvexPersistenceAdapter(client, PROJECT_ID).records(submissionRecordKind)
150
+ }
151
+
152
+ /** An asset an editor published a day before the attacker's intent, harvested from the public snapshot. */
153
+ async function harvestedOldAsset(t: Backend): Promise<string> {
154
+ vi.setSystemTime(at(-24 * 60 * 60 * 1000))
155
+ await publishAsset(t, 'hero', 400, 'image/jpeg')
156
+ vi.setSystemTime(new Date(T0))
157
+ return harvest(t, 'hero')
158
+ }
159
+
160
+ /** A refused submission whose second claim is a genuine mismatch — the shape that triggers the rollback. */
161
+ async function postWithRefusedSecondClaim(t: Backend, first: { uploadId: string; storageId: string }) {
162
+ const liar = await declare(t, 'image/png', 500)
163
+ const bad = await storeFile(t, 10, 'text/html')
164
+ return post(t, { ...SUBMISSION, uploads: [first, { uploadId: liar, storageId: bad }] })
165
+ }
166
+
167
+ describe('EC-671 probe — a visitor names an asset’s storage id in an upload claim', () => {
168
+ it('control: the anonymous delivery snapshot names the asset’s storage id (the attacker’s input is public)', async () => {
169
+ const t = await backend()
170
+ vi.setSystemTime(at(-24 * 60 * 60 * 1000))
171
+ const storageKey = await publishAsset(t, 'hero', 400, 'image/jpeg')
172
+ vi.setSystemTime(new Date(T0))
173
+ expect(await harvest(t, 'hero')).toBe(storageKey)
174
+ })
175
+
176
+ it('(1) mismatching declaration: the claim is refused and the asset’s file survives', async () => {
177
+ const t = await backend()
178
+ const victim = await harvestedOldAsset(t)
179
+ const uploadId = await declare(t, 'image/png', 10) // a fresh intent of the attacker's own, mismatching the asset
180
+ const answer = await post(t, { ...SUBMISSION, uploads: [{ uploadId, storageId: victim }] })
181
+ expect(answer.status).toBe(REFUSED_MISMATCHING)
182
+ expect(await rows(t)).toEqual([])
183
+ expect(await survives(t, victim)).toBe(true) // base: false — the claim deleted it (ingest.ts:256)
184
+ })
185
+
186
+ it('(2a) matching declaration, rollback: the asset’s file is never claimed, so a refused later claim cannot release it', async () => {
187
+ const t = await backend()
188
+ const victim = await harvestedOldAsset(t)
189
+ const matching = await declare(t, 'image/jpeg', 400)
190
+ const answer = await postWithRefusedSecondClaim(t, { uploadId: matching, storageId: victim })
191
+ expect(answer.status).toBe(422) // base: 415 — the first claim passed, the second refused
192
+ expect(await rows(t)).toEqual([])
193
+ expect(await survives(t, victim)).toBe(true) // base: false — claimed, then released by the rollback
194
+ })
195
+
196
+ it('(2b) matching declaration, removal: the claim cannot attach the asset’s file; a row naming it by hand cannot delete it', async () => {
197
+ const t = await backend()
198
+ const victim = await harvestedOldAsset(t)
199
+ const matching = await declare(t, 'image/jpeg', 400)
200
+ const answer = await post(t, {
201
+ ...SUBMISSION,
202
+ data: { ...SUBMISSION.data, cv: 'cv.jpg' },
203
+ uploads: [{ uploadId: matching, storageId: victim }],
204
+ })
205
+ expect(answer.status).toBe(422) // base: 200 — claimed and attached
206
+ expect(await rows(t)).toEqual([])
207
+ // The deletion site holds on its own: a submission whose `files` name the asset's id (an
208
+ // editor's write — the kind's `save` takes the column) is removed, and the asset's file stays.
209
+ const repo = editor(t)
210
+ await repo.save(PROJECT_ID, {
211
+ id: 'submission_by_hand',
212
+ form: 'contact',
213
+ payload: [],
214
+ files: [{ field: 'cv', storageId: victim, contentType: 'image/jpeg', size: 400 }],
215
+ status: 'new',
216
+ spam: 'clean',
217
+ deliveryState: 'skipped',
218
+ submittedAt: T0,
219
+ })
220
+ expect((await rows(t)).map((row) => row.id)).toEqual(['submission_by_hand'])
221
+ await repo.remove(PROJECT_ID, 'submission_by_hand')
222
+ expect(await rows(t)).toEqual([])
223
+ expect(await survives(t, victim)).toBe(true) // base: false — removal deletes whatever `files` names
224
+ })
225
+
226
+ it('(3) fresh asset (critic): uploaded AFTER the intent, a matching declaration, a refused second claim → it survives', async () => {
227
+ const t = await backend()
228
+ const matching = await declare(t, 'image/jpeg', 400) // the attacker holds a fresh intent open…
229
+ vi.setSystemTime(at(60 * 1000))
230
+ await publishAsset(t, 'hero', 400, 'image/jpeg') // …an editor uploads an asset a minute later…
231
+ const victim = await harvest(t, 'hero') // …which is public at once
232
+ const answer = await postWithRefusedSecondClaim(t, { uploadId: matching, storageId: victim })
233
+ expect(answer.status).toBe(422) // base: 415 — the first claim passed, the second refused
234
+ expect(await rows(t)).toEqual([])
235
+ expect(await survives(t, victim)).toBe(true) // base: false — claimed, then released by the rollback
236
+ })
237
+
238
+ it('control: a genuine upload claims and attaches; removing its submission deletes its file (the instrument sees a deletion)', async () => {
239
+ const t = await backend()
240
+ const uploadId = await declare(t, 'application/pdf', 900)
241
+ const own = await storeFile(t, 800, 'application/pdf')
242
+ const answer = await post(t, { ...SUBMISSION, data: { ...SUBMISSION.data, cv: 'cv.pdf' }, uploads: [{ uploadId, storageId: own }] })
243
+ expect(answer.status).toBe(200)
244
+ const [row] = await rows(t)
245
+ expect(row!.files).toEqual([
246
+ { field: 'cv', name: 'cv.pdf', storageId: own, contentType: 'application/pdf', size: 800, url: expect.any(String) },
247
+ ])
248
+ expect(await survives(t, own)).toBe(true)
249
+ await editor(t).remove(PROJECT_ID, row!.id)
250
+ expect(await survives(t, own)).toBe(false)
251
+ })
252
+
253
+ it('control: a genuine mismatch is refused (no row); a genuine claim before it is released by the rollback', async () => {
254
+ const t = await backend()
255
+ const good = await declare(t, 'image/png', 500)
256
+ const goodFile = await storeFile(t, 400, 'image/png')
257
+ const answer = await postWithRefusedSecondClaim(t, { uploadId: good, storageId: goodFile })
258
+ expect(answer.status).toBe(415)
259
+ expect(await rows(t)).toEqual([])
260
+ expect(await survives(t, goodFile)).toBe(false)
261
+ })
262
+ })
@@ -406,7 +406,7 @@ describe('uploads, claimed by the submission', () => {
406
406
  expect(await t.run((ctx) => ctx.storage.get(storageId as never))).toBeNull()
407
407
  })
408
408
 
409
- it('a claim that fails refuses the submission AND deletes the files it had already claimed', async () => {
409
+ it('a claim that fails refuses the submission AND deletes the files it had already claimed (never the refused one)', async () => {
410
410
  const t = await backend()
411
411
  site()
412
412
  const good = await declareUpload(t, 'image/png', 500, 'photo')
@@ -423,7 +423,8 @@ describe('uploads, claimed by the submission', () => {
423
423
  expect(answer.status).toBe(415)
424
424
  expect(await rows(t)).toEqual([])
425
425
  expect(await t.run((ctx) => ctx.storage.get(goodFile as never))).toBeNull() // claimed, then released
426
- expect(await t.run((ctx) => ctx.storage.get(wrongType as never))).toBeNull() // EC-617's claim deletes a mismatch
426
+ // EC-671: the refused claim never deletes the id it names (the visitor's word, not an ownership fact).
427
+ expect(await t.run(async (ctx) => (await ctx.storage.get(wrongType as never)) !== null)).toBe(true)
427
428
  expect(await scheduled(t)).toEqual([])
428
429
  })
429
430
 
@@ -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,23 @@ export const save = mutation({
130
133
  },
131
134
  })
132
135
 
133
- /** Delete a submission AND the files it claimed (no bytes outlive their row). */
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.
140
+ */
134
141
  export const remove = mutation({
135
142
  args: { [kind.idKey]: v.string() },
136
143
  handler: async (ctx, args: Record<string, unknown>) => {
137
144
  await requireCapability(ctx, kind.access.write)
138
145
  const doc = await findRecordDoc(ctx, kind, args[kind.idKey] as string)
139
146
  if (!doc) return
140
- await deleteFiles(ctx, (doc.data['files'] as SubmissionFile[] | undefined) ?? [])
147
+ await releaseIngestUploads(ctx, storageIds((doc.data['files'] as SubmissionFile[] | undefined) ?? []))
141
148
  await ctx.db.delete(doc._id)
142
149
  },
143
150
  })
144
151
 
145
- async function deleteFiles(ctx: MutationCtx, files: readonly { storageId: string }[]): Promise<void> {
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
- }
152
+ const storageIds = (files: readonly { storageId: string }[]) => files.map((file) => file.storageId)
151
153
 
152
154
  // --- The receiver's handler ------------------------------------------------------------------
153
155
 
@@ -172,13 +174,14 @@ export const ingest = internalMutation({
172
174
  const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
173
175
  if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
174
176
 
175
- // Claim every upload; on the first refusal, delete what this submission already claimed.
177
+ // Claim every upload; on the first refusal, release what this submission already claimed
178
+ // (bound in this transaction — EC-671: nothing else is ever deleted here).
176
179
  const files: SubmissionFile[] = []
177
180
  const namesUsed = new Map<string, number>()
178
181
  for (const claim of body.uploads ?? []) {
179
182
  const claimed = await claimIngestUpload(ctx, claim, body.target, receivedAt)
180
183
  if (!claimed.ok) {
181
- await deleteFiles(ctx, files)
184
+ await releaseIngestUploads(ctx, storageIds(files))
182
185
  return claimed
183
186
  }
184
187
  const index = namesUsed.get(claimed.field) ?? 0