@elytracms/studio 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/convex/assets.ts +16 -2
- package/convex/cms.ts +153 -13
- package/convex/delivery-folded-drift.test.ts +104 -0
- package/convex/delivery.ts +24 -8
- package/convex/depth-codec-lookalike.test.ts +192 -0
- package/convex/fixture-accounts.test.ts +305 -0
- package/convex/fixtureAccounts.ts +298 -0
- package/convex/incarnation.test.ts +149 -0
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +327 -9
- package/convex/ingest.ts +242 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/dist/components/cms/cms-store.d.ts +6 -5
- package/dist/components/cms/cms-store.js +14 -34
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +16 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +76 -4
- package/dist/components/cms/use-document-draft.js +94 -25
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +5 -0
- package/dist/lib/i18n/messages.en.js +5 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +49 -5
- package/dist/lib/operations/cms-persistence.js +237 -47
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +27 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +5 -3
package/convex/ingest.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
INGEST_MAX_BODY_BYTES,
|
|
7
7
|
INGEST_RECEIVER_PATH,
|
|
8
8
|
INGEST_SIGNATURE_HEADER,
|
|
9
|
+
ingestUploadSha256,
|
|
9
10
|
signedIngestRequest,
|
|
10
11
|
} from '@elytracms/core/ingest'
|
|
11
12
|
import type { IngestHandlerResult, IngestPayload } from '@elytracms/core/ingest'
|
|
@@ -13,7 +14,15 @@ import { defineRecordKind } from '@elytracms/core/persistence'
|
|
|
13
14
|
import schema from './schema'
|
|
14
15
|
import { internalMutation } from './functions'
|
|
15
16
|
import { defineStudioHttp } from './http'
|
|
16
|
-
import {
|
|
17
|
+
import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
|
|
18
|
+
import {
|
|
19
|
+
claimIngestUpload,
|
|
20
|
+
ingestHandlerArgs,
|
|
21
|
+
parseIngestPayload,
|
|
22
|
+
recordIngestUpload,
|
|
23
|
+
releaseIngestUploads,
|
|
24
|
+
unclaimedIngestUploads,
|
|
25
|
+
} from './ingest'
|
|
17
26
|
import type { IngestHandlerRef } from './ingest'
|
|
18
27
|
import { saveRecord } from './record-functions'
|
|
19
28
|
import { createIngestRoute } from '../../next/src/ingest'
|
|
@@ -178,6 +187,27 @@ async function storeFile(t: ReturnType<typeof backend>, bytes: number, contentTy
|
|
|
178
187
|
})
|
|
179
188
|
}
|
|
180
189
|
|
|
190
|
+
/** EC-685: the SHA-256 an upload declares for the zero-filled `bytes` bytes {@link storeFile} stores. */
|
|
191
|
+
async function sha256Of(bytes: number): Promise<string> {
|
|
192
|
+
const digest = await ingestUploadSha256(new Uint8Array(bytes))
|
|
193
|
+
if (!digest.ok) throw new Error(digest.message)
|
|
194
|
+
return digest.sha256
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether stored bytes exist (`storage.get` yields a Blob, which is not a Convex value: answer a boolean). */
|
|
198
|
+
function stored(t: ReturnType<typeof backend>, storageId: string): Promise<boolean> {
|
|
199
|
+
return t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** An upload intent's claim columns, as stored. */
|
|
203
|
+
function intentState(t: ReturnType<typeof backend>, uploadId: string) {
|
|
204
|
+
return t.run(async (ctx) => {
|
|
205
|
+
const row = (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestUpload' && r.recordId === uploadId)
|
|
206
|
+
const data = row?.data as { state?: string; storageId?: string } | undefined
|
|
207
|
+
return { state: data?.state, storageId: data?.storageId }
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
181
211
|
/** Sign an arbitrary raw body (for the invalid-shape case the typed builder refuses to build). */
|
|
182
212
|
async function signedIngestRequestRaw(raw: string): Promise<string> {
|
|
183
213
|
const { signRevalidationBody } = await import('@elytracms/core/revalidation')
|
|
@@ -243,7 +273,7 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
243
273
|
it('an upload the handler accepts gets ONE storage URL and an upload id; one it refuses gets none', async () => {
|
|
244
274
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
245
275
|
const t = backend()
|
|
246
|
-
const upload = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
276
|
+
const upload = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500, sha256: await sha256Of(500) } as const
|
|
247
277
|
const issued = await signedPost(t, upload)
|
|
248
278
|
expect(issued.status).toBe(200)
|
|
249
279
|
const body = (await issued.json()) as { uploadId: string; uploadUrl: string; expiresInSeconds: number }
|
|
@@ -252,32 +282,56 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
252
282
|
expect(body.expiresInSeconds).toBe(3600)
|
|
253
283
|
expect((await signedPost(t, { ...upload, size: 5000 })).status).toBe(413) // the handler's policy
|
|
254
284
|
expect((await signedPost(t, { ...upload, contentType: 'application/x-msdownload' })).status).toBe(415)
|
|
285
|
+
// EC-685: the intent records the declared digest (what the claim compares).
|
|
286
|
+
const intent = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((row) => row.recordId === body.uploadId))
|
|
287
|
+
expect((intent!.data as { sha256: string }).sha256).toBe(upload.sha256)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('EC-685 wire: an upload that declares no sha256, or one in another encoding, is a 400 — no intent, no URL', async () => {
|
|
291
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
292
|
+
const t = backend()
|
|
293
|
+
const base = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
294
|
+
const hex = await sha256Of(500)
|
|
295
|
+
for (const payload of [base, { ...base, sha256: hex.toUpperCase() }, { ...base, sha256: hex.slice(1) }]) {
|
|
296
|
+
const raw = JSON.stringify(payload)
|
|
297
|
+
const answer = await t.fetch(INGEST_RECEIVER_PATH, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signedIngestRequestRaw(raw) },
|
|
300
|
+
body: raw,
|
|
301
|
+
})
|
|
302
|
+
expect(answer.status, raw).toBe(400) // the receiver's union (payload | probe) reports it at the root; the route's read names `sha256` (core test)
|
|
303
|
+
}
|
|
304
|
+
expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).length)).toBe(0)
|
|
305
|
+
// Control: the declared lowercase hex is accepted.
|
|
306
|
+
expect((await signedPost(t, { ...base, sha256: hex })).status).toBe(200)
|
|
255
307
|
})
|
|
256
308
|
|
|
257
|
-
it('a submission claims its upload: the stored file must match the declaration; a mismatch is deleted', async () => {
|
|
309
|
+
it('a submission claims its upload: the stored file must match the declaration; a mismatch is refused, never deleted', async () => {
|
|
258
310
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
259
311
|
const t = backend()
|
|
260
|
-
const declare = async (contentType: string, size: number) =>
|
|
261
|
-
((await (await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size })).json()) as {
|
|
312
|
+
const declare = async (contentType: string, size: number, bytesOf: number) =>
|
|
313
|
+
((await (await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size, sha256: await sha256Of(bytesOf) })).json()) as {
|
|
262
314
|
uploadId: string
|
|
263
315
|
}).uploadId
|
|
264
|
-
const good = await declare('image/png', 500)
|
|
316
|
+
const good = await declare('image/png', 500, 400)
|
|
265
317
|
const goodFile = await storeFile(t, 400, 'image/png')
|
|
266
318
|
const ok = await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: good, storageId: goodFile }] })
|
|
267
319
|
expect(ok.status).toBe(200)
|
|
268
320
|
// A second claim of the same upload is a conflict.
|
|
269
321
|
expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: good, storageId: goodFile }] })).status).toBe(409)
|
|
270
322
|
|
|
271
|
-
const liar = await declare('image/png', 500)
|
|
323
|
+
const liar = await declare('image/png', 500, 10) // its own bytes: the mismatch is the type
|
|
272
324
|
const wrongType = await storeFile(t, 10, 'text/html')
|
|
273
325
|
expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: liar, storageId: wrongType }] })).status).toBe(415)
|
|
274
|
-
|
|
326
|
+
// EC-671: refused and KEPT — a claim never deletes the id it names (it is the visitor's word).
|
|
327
|
+
expect(await stored(t, wrongType)).toBe(true)
|
|
328
|
+
expect(await intentState(t, liar)).toEqual({ state: 'rejected', storageId: wrongType })
|
|
275
329
|
})
|
|
276
330
|
|
|
277
331
|
it('an upload no submission claims is listed once its life is over (the visible orphan finding)', async () => {
|
|
278
332
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
279
333
|
const t = backend()
|
|
280
|
-
await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 10 })
|
|
334
|
+
await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 10, sha256: await sha256Of(10) })
|
|
281
335
|
const later = (offsetMs: number) => new Date(Date.parse('2026-10-26T12:00:00.000Z') + offsetMs).toISOString()
|
|
282
336
|
expect(await t.run((ctx) => unclaimedIngestUploads(ctx, 'contact', later(30 * 60 * 1000)))).toEqual([])
|
|
283
337
|
const orphans = await t.run((ctx) => unclaimedIngestUploads(ctx, 'contact', later(2 * 60 * 60 * 1000)))
|
|
@@ -285,6 +339,270 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
285
339
|
})
|
|
286
340
|
})
|
|
287
341
|
|
|
342
|
+
/**
|
|
343
|
+
* EC-671 — a claim binds only bytes the deployment's own facts say are this intent's, each
|
|
344
|
+
* check isolated (the others pass by construction) beside a control the same harness claims;
|
|
345
|
+
* and `releaseIngestUploads` deletes only what a claim bound. The forms addon's end-to-end
|
|
346
|
+
* probe (its rollback and removal, the attacker's harvest) is
|
|
347
|
+
* `packages/forms/src/convex/claim-ownership.convex.test.ts`.
|
|
348
|
+
*/
|
|
349
|
+
describe('the claim binds only this intent’s bytes; release deletes only bound bytes (EC-671)', () => {
|
|
350
|
+
const PNG = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
351
|
+
/** An intent declaring a png of up to 500 bytes whose content is the `bytesOf` zero bytes the test then stores (EC-685). */
|
|
352
|
+
async function declare(t: ReturnType<typeof backend>, bytesOf = 400): Promise<string> {
|
|
353
|
+
return ((await (await signedPost(t, { ...PNG, sha256: await sha256Of(bytesOf) })).json()) as { uploadId: string }).uploadId
|
|
354
|
+
}
|
|
355
|
+
const claimWith = (t: ReturnType<typeof backend>, uploadId: string, storageId: string) =>
|
|
356
|
+
signedPost(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
|
|
357
|
+
async function insertAsset(t: ReturnType<typeof backend>, files: { storageKey: string; thumb?: string }) {
|
|
358
|
+
await t.run(async (ctx) => {
|
|
359
|
+
await ctx.db.insert('assets', {
|
|
360
|
+
assetId: `asset_${files.storageKey}`,
|
|
361
|
+
filename: 'hero.png',
|
|
362
|
+
contentType: 'image/png',
|
|
363
|
+
byteSize: 400,
|
|
364
|
+
storageKey: files.storageKey,
|
|
365
|
+
...(files.thumb ? { thumb: { storageKey: files.thumb, url: 'https://cdn.invalid/t', width: 1, height: 1 } } : {}),
|
|
366
|
+
createdAt: '2026-10-26T12:00:00.000Z',
|
|
367
|
+
updatedAt: '2026-10-26T12:00:00.000Z',
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
const bindings = (t: ReturnType<typeof backend>) =>
|
|
372
|
+
t.run(async (ctx) =>
|
|
373
|
+
(await ctx.db.query('records').collect())
|
|
374
|
+
.filter((row) => row.kind === 'ingestBinding')
|
|
375
|
+
.map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
it('(1) a file stored BEFORE the intent — in the same millisecond — is refused, rejected and kept; one stored after is claimed', async () => {
|
|
379
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
380
|
+
const t = backend()
|
|
381
|
+
const earlier = await storeFile(t, 400, 'image/png') // the fake clock does not move: one millisecond for all
|
|
382
|
+
const uploadId = await declare(t)
|
|
383
|
+
// Why the rule reads the intent ROW's `_creationTime`, not `issuedAt`: in this millisecond
|
|
384
|
+
// the earlier file is NOT before `issuedAt` (the HTTP action's clock, ms grain) — that
|
|
385
|
+
// comparison would have admitted it. Both `_creationTime`s are the deployment's stamps.
|
|
386
|
+
const facts = await t.run(async (ctx) => {
|
|
387
|
+
const intent = (await ctx.db.query('records').collect()).find((row) => row.recordId === uploadId)!
|
|
388
|
+
const file = (await ctx.db.system.get(earlier as never)) as { _creationTime: number }
|
|
389
|
+
return { file: file._creationTime, issuedAt: Date.parse((intent.data as { issuedAt: string }).issuedAt), intent: intent._creationTime }
|
|
390
|
+
})
|
|
391
|
+
expect(facts.file).toBeGreaterThanOrEqual(facts.issuedAt)
|
|
392
|
+
expect(facts.file).toBeLessThan(facts.intent)
|
|
393
|
+
const refused = await claimWith(t, uploadId, earlier)
|
|
394
|
+
expect(refused.status).toBe(422)
|
|
395
|
+
expect(await refused.json()).toEqual({ error: `Upload “${uploadId}” did not store the file “${earlier}”.` })
|
|
396
|
+
expect(await intentState(t, uploadId)).toEqual({ state: 'rejected', storageId: earlier })
|
|
397
|
+
expect(await stored(t, earlier)).toBe(true)
|
|
398
|
+
expect(await bindings(t)).toEqual([])
|
|
399
|
+
// Control: a file stored after its intent is claimed and bound.
|
|
400
|
+
const fresh = await declare(t)
|
|
401
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
402
|
+
expect((await claimWith(t, fresh, own)).status).toBe(200)
|
|
403
|
+
expect(await bindings(t)).toEqual([{ storageId: own, uploadId: fresh }])
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('(2) a file another claim bound is refused for a second intent issued before it was stored', async () => {
|
|
407
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
408
|
+
const t = backend()
|
|
409
|
+
const first = await declare(t)
|
|
410
|
+
const second = await declare(t) // issued BEFORE the file, so check (1) passes for it too
|
|
411
|
+
const file = await storeFile(t, 400, 'image/png')
|
|
412
|
+
expect((await claimWith(t, first, file)).status).toBe(200) // control: the first claim binds it
|
|
413
|
+
expect((await claimWith(t, second, file)).status).toBe(422)
|
|
414
|
+
expect(await intentState(t, second)).toEqual({ state: 'rejected', storageId: file })
|
|
415
|
+
expect(await bindings(t)).toEqual([{ storageId: file, uploadId: first }])
|
|
416
|
+
expect(await stored(t, file)).toBe(true)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('(3) a file an asset row references — as its file or as its preview — is refused; control: an unreferenced one is claimed', async () => {
|
|
420
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
421
|
+
const t = backend()
|
|
422
|
+
const [asFile, asThumb, plain] = [await declare(t), await declare(t, 40), await declare(t)]
|
|
423
|
+
const original = await storeFile(t, 400, 'image/png') // all stored AFTER the three intents: check (1) passes
|
|
424
|
+
const preview = await storeFile(t, 40, 'image/png')
|
|
425
|
+
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
426
|
+
await insertAsset(t, { storageKey: original, thumb: preview })
|
|
427
|
+
expect((await claimWith(t, asFile, original)).status).toBe(422)
|
|
428
|
+
expect((await claimWith(t, asThumb, preview)).status).toBe(422)
|
|
429
|
+
expect((await claimWith(t, plain, unreferenced)).status).toBe(200)
|
|
430
|
+
expect(await bindings(t)).toEqual([{ storageId: unreferenced, uploadId: plain }])
|
|
431
|
+
expect([await stored(t, original), await stored(t, preview)]).toEqual([true, true])
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('releaseIngestUploads deletes a bound file and its binding, keeps an unbound one (an asset’s), and dedupes', async () => {
|
|
435
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
436
|
+
const t = backend()
|
|
437
|
+
const uploadId = await declare(t)
|
|
438
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
439
|
+
expect((await claimWith(t, uploadId, own)).status).toBe(200)
|
|
440
|
+
const assetFile = await storeFile(t, 400, 'image/png')
|
|
441
|
+
await insertAsset(t, { storageKey: assetFile })
|
|
442
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, [own, assetFile, own]))
|
|
443
|
+
expect(released).toEqual({ deleted: [own], kept: [assetFile] })
|
|
444
|
+
expect([await stored(t, own), await stored(t, assetFile)]).toEqual([false, true])
|
|
445
|
+
expect(await bindings(t)).toEqual([])
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
|
|
449
|
+
// asset may acquire a bound file afterwards. Each delete path re-checks the OTHER owner.
|
|
450
|
+
async function asEditor(t: ReturnType<typeof backend>) {
|
|
451
|
+
await t.run(async (ctx) => {
|
|
452
|
+
await ctx.db.insert('members', { userId: 'user_editor', role: 'admin', createdAt: '2026-10-26T12:00:00.000Z', updatedAt: '2026-10-26T12:00:00.000Z' })
|
|
453
|
+
})
|
|
454
|
+
return t.withIdentity({ subject: 'user_editor', issuer: 'https://convex.test' })
|
|
455
|
+
}
|
|
456
|
+
const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
|
|
457
|
+
|
|
458
|
+
it('EC-685: release KEEPS a bound file an asset references now — the binding is spent, the bytes go with the asset', async () => {
|
|
459
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
460
|
+
const t = backend()
|
|
461
|
+
const [asFile, asThumb, plain] = [await declare(t), await declare(t, 40), await declare(t)]
|
|
462
|
+
const promoted = await storeFile(t, 400, 'image/png')
|
|
463
|
+
const preview = await storeFile(t, 40, 'image/png')
|
|
464
|
+
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
465
|
+
for (const [uploadId, file] of [[asFile, promoted], [asThumb, preview], [plain, unreferenced]] as const) {
|
|
466
|
+
expect((await claimWith(t, uploadId, file)).status).toBe(200)
|
|
467
|
+
}
|
|
468
|
+
// An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
|
|
469
|
+
await insertAsset(t, { storageKey: promoted, thumb: preview })
|
|
470
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, [promoted, preview, unreferenced]))
|
|
471
|
+
expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
|
|
472
|
+
expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
|
|
473
|
+
expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
|
|
474
|
+
// …so the asset's own delete now deletes them (control: deleteBlob deletes unbound bytes).
|
|
475
|
+
const editor = await asEditor(t)
|
|
476
|
+
expect(await editor.mutation(deleteBlob, { storageKey: promoted })).toEqual({ deleted: true })
|
|
477
|
+
expect(await stored(t, promoted)).toBe(false)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('EC-685 mirror: assets:deleteBlob KEEPS bytes an ingest binding holds and returns normally; release then deletes them', async () => {
|
|
481
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
482
|
+
const t = backend()
|
|
483
|
+
const uploadId = await declare(t)
|
|
484
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
485
|
+
expect((await claimWith(t, uploadId, own)).status).toBe(200)
|
|
486
|
+
await insertAsset(t, { storageKey: own }) // promoted to an asset while the submission still holds it
|
|
487
|
+
const editor = await asEditor(t)
|
|
488
|
+
expect(await editor.mutation(deleteBlob, { storageKey: own })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
489
|
+
expect(await stored(t, own)).toBe(true)
|
|
490
|
+
expect(await bindings(t)).toEqual([{ storageId: own, uploadId }])
|
|
491
|
+
// The asset row goes (the studio's `assets:remove`); the submission's release deletes the bytes.
|
|
492
|
+
await t.run(async (ctx) => {
|
|
493
|
+
for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
|
|
494
|
+
})
|
|
495
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, [own]))).toEqual({ deleted: [own], kept: [] })
|
|
496
|
+
expect(await stored(t, own)).toBe(false)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// EC-685 (Codex r4 F20, ordering 1): checks (1)–(3) are eligibility; (4) is causation.
|
|
500
|
+
async function storeBytes(t: ReturnType<typeof backend>, bytes: Uint8Array<ArrayBuffer>, contentType: string) {
|
|
501
|
+
return t.run(async (ctx) => {
|
|
502
|
+
const id = await ctx.storage.store(new Blob([bytes], { type: contentType }))
|
|
503
|
+
await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
|
|
504
|
+
return id
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
const setStoredDigest = (t: ReturnType<typeof backend>, storageId: string, sha256: string) =>
|
|
508
|
+
t.run(async (ctx) => {
|
|
509
|
+
await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(storageId, { sha256 })
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('(4) EC-685: a file stored after the intent, unbound, no asset’s — but not the DECLARED bytes — is refused, rejected and kept', async () => {
|
|
513
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
514
|
+
const t = backend()
|
|
515
|
+
const uploadId = await declare(t) // declares 400 zero bytes
|
|
516
|
+
const other = await storeBytes(t, new Uint8Array(400).fill(7), 'image/png') // same type and size, other bytes
|
|
517
|
+
const refused = await claimWith(t, uploadId, other)
|
|
518
|
+
expect(refused.status).toBe(422)
|
|
519
|
+
// The ONE answer of checks (1)–(4): which one failed is not the visitor's to learn.
|
|
520
|
+
expect(await refused.json()).toEqual({ error: `Upload “${uploadId}” did not store the file “${other}”.` })
|
|
521
|
+
expect(await intentState(t, uploadId)).toEqual({ state: 'rejected', storageId: other })
|
|
522
|
+
expect(await stored(t, other)).toBe(true)
|
|
523
|
+
expect(await bindings(t)).toEqual([])
|
|
524
|
+
// Control: the declared bytes, stored after their intent, are claimed.
|
|
525
|
+
const fresh = await declare(t)
|
|
526
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
527
|
+
expect((await claimWith(t, fresh, own)).status).toBe(200)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('EC-685: the comparator reads the stored digest by its FORM — convex-test’s base64 (measured) and hex both match', async () => {
|
|
531
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
532
|
+
const t = backend()
|
|
533
|
+
const first = await declare(t)
|
|
534
|
+
const asStored = await storeFile(t, 400, 'image/png')
|
|
535
|
+
const stamped = await t.run(async (ctx) => ((await ctx.db.system.get(asStored as never)) as { sha256: string }).sha256)
|
|
536
|
+
expect(stamped).toMatch(/^[A-Za-z0-9+/]{43}=$/) // convex-test 0.0.54 fills `_storage.sha256` with base64 (`blobSha`)
|
|
537
|
+
expect((await claimWith(t, first, asStored)).status).toBe(200)
|
|
538
|
+
// The same digest in hex (`storage.getMetadata`'s documented form) reads the same.
|
|
539
|
+
const second = await declare(t)
|
|
540
|
+
const asHex = await storeFile(t, 400, 'image/png')
|
|
541
|
+
await setStoredDigest(t, asHex, (await sha256Of(400)).toUpperCase())
|
|
542
|
+
expect((await claimWith(t, second, asHex)).status).toBe(200)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('EC-685: a stored digest in NEITHER form is refused visibly with its own reason, never read as a match', async () => {
|
|
546
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
547
|
+
const t = backend()
|
|
548
|
+
const uploadId = await declare(t)
|
|
549
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
550
|
+
// A base64url spelling of the right digest is still neither form: refused, not guessed at.
|
|
551
|
+
const url = btoa(String.fromCharCode(...Array.from((await sha256Of(400)).match(/../g)!, (pair) => parseInt(pair, 16))))
|
|
552
|
+
.replace(/\+/g, '-')
|
|
553
|
+
.replace(/\//g, '_')
|
|
554
|
+
.replace(/=+$/, '')
|
|
555
|
+
await setStoredDigest(t, own, url)
|
|
556
|
+
const refused = await claimWith(t, uploadId, own)
|
|
557
|
+
expect(refused.status).toBe(422)
|
|
558
|
+
expect(await refused.json()).toEqual({
|
|
559
|
+
error: `The deployment's stored digest of “${own}” is in neither form the claim reads (64 hex or 44 base64 characters), so the upload cannot be verified.`,
|
|
560
|
+
})
|
|
561
|
+
expect(await intentState(t, uploadId)).toEqual({ state: 'rejected', storageId: own })
|
|
562
|
+
expect(await bindings(t)).toEqual([])
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('LEGACY (EC-685): an intent issued before uploads declared a digest is refused before any write — no crash, the row untouched', async () => {
|
|
566
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
567
|
+
const t = backend()
|
|
568
|
+
const issuedAt = '2026-10-26T12:00:00.000Z'
|
|
569
|
+
await t.run(async (ctx) => {
|
|
570
|
+
// A row as the previous release wrote it: no `sha256` column (the kind now requires one).
|
|
571
|
+
await ctx.db.insert('records', {
|
|
572
|
+
kind: 'ingestUpload',
|
|
573
|
+
recordId: 'upload_legacy',
|
|
574
|
+
data: { target: 'contact', field: 'cv', contentType: 'image/png', size: 500, state: 'issued', issuedAt },
|
|
575
|
+
key1: 'contact',
|
|
576
|
+
key2: 'issued',
|
|
577
|
+
at: issuedAt,
|
|
578
|
+
createdAt: issuedAt,
|
|
579
|
+
updatedAt: issuedAt,
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
const file = await storeFile(t, 400, 'image/png')
|
|
583
|
+
const refused = await claimWith(t, 'upload_legacy', file)
|
|
584
|
+
expect(refused.status).toBe(422)
|
|
585
|
+
expect(await refused.json()).toEqual({
|
|
586
|
+
error: 'Upload “upload_legacy” was issued before uploads declared their SHA-256 — upload the file again.',
|
|
587
|
+
})
|
|
588
|
+
expect(await intentState(t, 'upload_legacy')).toEqual({ state: 'issued', storageId: undefined })
|
|
589
|
+
expect(await bindings(t)).toEqual([])
|
|
590
|
+
expect(await stored(t, file)).toBe(true)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('the binding row is flat — its stored nesting is far inside Convex’s limit', async () => {
|
|
594
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
595
|
+
const t = backend()
|
|
596
|
+
const uploadId = await declare(t)
|
|
597
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
598
|
+
expect((await claimWith(t, uploadId, own)).status).toBe(200)
|
|
599
|
+
const row = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestBinding')!)
|
|
600
|
+
expect(row.data).toEqual({ uploadId, target: 'contact', boundAt: '2026-10-26T12:00:00.000Z' })
|
|
601
|
+
expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
|
|
602
|
+
expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
|
|
288
606
|
describe('the site’s route → the receiver, end to end (EC-617)', () => {
|
|
289
607
|
it('a visitor’s submission through createIngestRoute lands a record; a mismatched site secret is a 502', async () => {
|
|
290
608
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|