@elytracms/forms 0.0.96 → 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.
|
|
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.
|
|
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.
|
|
37
|
-
"@elytracms/studio": "^0.0.
|
|
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/
|
|
63
|
-
"@elytracms/
|
|
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
|
-
|
|
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
|
|
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,23 @@ 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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
184
|
+
await releaseIngestUploads(ctx, storageIds(files))
|
|
182
185
|
return claimed
|
|
183
186
|
}
|
|
184
187
|
const index = namesUsed.get(claimed.field) ?? 0
|