@elytracms/studio 0.0.98 → 0.0.99

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.
Files changed (91) hide show
  1. package/convex/ingest.test.ts +84 -7
  2. package/convex/ingest.ts +110 -31
  3. package/convex/path-segment-refusal.test.ts +201 -0
  4. package/convex/records-column-transform.convex.test.ts +147 -0
  5. package/convex/schema.ts +1 -1
  6. package/dist/addon.d.ts +5 -3
  7. package/dist/addon.js +4 -3
  8. package/dist/addon.js.map +1 -1
  9. package/dist/components/app-views/default-modules.d.ts +10 -3
  10. package/dist/components/app-views/default-modules.js +5 -1
  11. package/dist/components/app-views/default-modules.js.map +1 -1
  12. package/dist/components/app-views/module.d.ts +15 -8
  13. package/dist/components/app-views/module.js.map +1 -1
  14. package/dist/components/builder/reuse-context.d.ts +29 -1
  15. package/dist/components/builder/reuse-context.js +58 -12
  16. package/dist/components/builder/reuse-context.js.map +1 -1
  17. package/dist/components/cms/cms-store.d.ts +3 -2
  18. package/dist/components/cms/cms-store.js +8 -2
  19. package/dist/components/cms/cms-store.js.map +1 -1
  20. package/dist/components/cms/field-control.js +15 -5
  21. package/dist/components/cms/field-control.js.map +1 -1
  22. package/dist/components/cms/use-document-draft.d.ts +4 -1
  23. package/dist/components/cms/use-document-draft.js +4 -1
  24. package/dist/components/cms/use-document-draft.js.map +1 -1
  25. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  26. package/dist/components/cms/use-publish-controller.js +17 -3
  27. package/dist/components/cms/use-publish-controller.js.map +1 -1
  28. package/dist/components/cms/versions-pane.js +8 -5
  29. package/dist/components/cms/versions-pane.js.map +1 -1
  30. package/dist/components/delivery-health-banner.d.ts +34 -16
  31. package/dist/components/delivery-health-banner.js +59 -27
  32. package/dist/components/delivery-health-banner.js.map +1 -1
  33. package/dist/components/issue-severity.d.ts +3 -2
  34. package/dist/components/issue-severity.js.map +1 -1
  35. package/dist/components/nav-issues-registry.d.ts +39 -24
  36. package/dist/components/nav-issues-registry.js +50 -21
  37. package/dist/components/nav-issues-registry.js.map +1 -1
  38. package/dist/components/nav-issues.d.ts +23 -50
  39. package/dist/components/nav-issues.js +31 -59
  40. package/dist/components/nav-issues.js.map +1 -1
  41. package/dist/components/notifications/notice-center.d.ts +61 -0
  42. package/dist/components/notifications/notice-center.js +119 -0
  43. package/dist/components/notifications/notice-center.js.map +1 -0
  44. package/dist/components/notifications/notice-model.d.ts +137 -0
  45. package/dist/components/notifications/notice-model.js +107 -0
  46. package/dist/components/notifications/notice-model.js.map +1 -0
  47. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  48. package/dist/components/notifications/outcome-notice.js +34 -0
  49. package/dist/components/notifications/outcome-notice.js.map +1 -0
  50. package/dist/components/notifications/shell-findings.d.ts +36 -0
  51. package/dist/components/notifications/shell-findings.js +140 -0
  52. package/dist/components/notifications/shell-findings.js.map +1 -0
  53. package/dist/components/rail-view-source.js +5 -0
  54. package/dist/components/rail-view-source.js.map +1 -1
  55. package/dist/components/records/record-rail.d.ts +10 -2
  56. package/dist/components/records/record-rail.js +74 -4
  57. package/dist/components/records/record-rail.js.map +1 -1
  58. package/dist/components/section-nav.d.ts +15 -3
  59. package/dist/components/section-nav.js +39 -147
  60. package/dist/components/section-nav.js.map +1 -1
  61. package/dist/components/studio-project-screen.js.map +1 -1
  62. package/dist/components/studio-shell.js +8 -3
  63. package/dist/components/studio-shell.js.map +1 -1
  64. package/dist/components/use-narrow-viewport.d.ts +11 -0
  65. package/dist/components/use-narrow-viewport.js +25 -0
  66. package/dist/components/use-narrow-viewport.js.map +1 -0
  67. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  68. package/dist/components/users/role-policy-drift-issues.js +17 -8
  69. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  70. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  71. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  72. package/dist/lib/engine-version.d.ts +1 -1
  73. package/dist/lib/engine-version.js +1 -1
  74. package/dist/lib/engine-version.js.map +1 -1
  75. package/dist/lib/i18n/messages.de.js +21 -3
  76. package/dist/lib/i18n/messages.de.js.map +1 -1
  77. package/dist/lib/i18n/messages.en.d.ts +19 -2
  78. package/dist/lib/i18n/messages.en.js +24 -4
  79. package/dist/lib/i18n/messages.en.js.map +1 -1
  80. package/dist/lib/notices/toast-store.d.ts +61 -0
  81. package/dist/lib/notices/toast-store.js +116 -0
  82. package/dist/lib/notices/toast-store.js.map +1 -0
  83. package/dist/lib/operations/cms-persistence.d.ts +24 -4
  84. package/dist/lib/operations/cms-persistence.js +417 -80
  85. package/dist/lib/operations/cms-persistence.js.map +1 -1
  86. package/dist/lib/persistence/convex-adapter.js +61 -30
  87. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  88. package/dist/ui/toast.d.ts +24 -16
  89. package/dist/ui/toast.js +23 -32
  90. package/dist/ui/toast.js.map +1 -1
  91. package/package.json +3 -3
@@ -16,7 +16,7 @@ import { internalMutation } from './functions'
16
16
  import { defineStudioHttp } from './http'
17
17
  import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
18
18
  import {
19
- claimIngestUpload,
19
+ claimIngestUploads,
20
20
  ingestHandlerArgs,
21
21
  parseIngestPayload,
22
22
  recordIngestUpload,
@@ -77,12 +77,10 @@ const testIngestModule = {
77
77
  }
78
78
  if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
79
79
  if (payload.target !== 'contact') return { ok: false, status: 404, message: `No form “${payload.target}”.` }
80
- const files: string[] = []
81
- for (const claim of payload.uploads ?? []) {
82
- const claimed = await claimIngestUpload(ctx, claim, payload.target, args.receivedAt)
83
- if (!claimed.ok) return claimed
84
- files.push(claimed.storageId)
85
- }
80
+ // EC-695: one call for every upload, after every check that can refuse — all bound or none.
81
+ const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt)
82
+ if (!claim.ok) return claim
83
+ const files = claim.files.map((file) => file.storageId)
86
84
  const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
87
85
  await saveRecord(
88
86
  ctx,
@@ -601,6 +599,85 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
601
599
  expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
602
600
  expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
603
601
  })
602
+
603
+ // EC-695 — the claim is ALL-OR-NOTHING, in the engine: every claim judged, then all bound.
604
+ const intents = (t: ReturnType<typeof backend>) =>
605
+ t.run(async (ctx) =>
606
+ Object.fromEntries(
607
+ (await ctx.db.query('records').collect())
608
+ .filter((row) => row.kind === 'ingestUpload')
609
+ .map((row) => [row.recordId, (row.data as { state: string }).state]),
610
+ ),
611
+ )
612
+ const claimDirect = (t: ReturnType<typeof backend>, claims: { uploadId: string; storageId: string }[]) =>
613
+ t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z'))
614
+
615
+ it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
616
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
617
+ const t = backend()
618
+ const [first, second, third] = [await declare(t), await declare(t), await declare(t)]
619
+ const [one, two] = [await storeFile(t, 400, 'image/png'), await storeFile(t, 400, 'image/png')]
620
+ const wrongType = await storeFile(t, 400, 'text/html') // the third's: the declared bytes, another type
621
+ const answer = await signedPost(t, {
622
+ ...SUBMISSION,
623
+ uploads: [
624
+ { uploadId: first, storageId: one },
625
+ { uploadId: second, storageId: two },
626
+ { uploadId: third, storageId: wrongType },
627
+ ],
628
+ })
629
+ expect(answer.status).toBe(415)
630
+ expect(await bindings(t)).toEqual([]) // per-claim binding would leave the first two bound, with no row
631
+ expect(await intents(t)).toEqual({ [first]: 'issued', [second]: 'issued', [third]: 'rejected' })
632
+ expect([await stored(t, one), await stored(t, two), await stored(t, wrongType)]).toEqual([true, true, true])
633
+ expect((await t.run(async (ctx) => ctx.db.query('records').collect())).some((row) => row.kind === 'testSubmission')).toBe(false)
634
+ // Control: the corrected submission (the same two uploads) binds both, in claim order.
635
+ const retry = await signedPost(t, {
636
+ ...SUBMISSION,
637
+ uploads: [
638
+ { uploadId: first, storageId: one },
639
+ { uploadId: second, storageId: two },
640
+ ],
641
+ })
642
+ expect(retry.status).toBe(200)
643
+ expect((await bindings(t)).map((binding) => binding.uploadId).sort()).toEqual([first, second].sort())
644
+ expect(await intents(t)).toEqual({ [first]: 'claimed', [second]: 'claimed', [third]: 'rejected' })
645
+ })
646
+
647
+ it('EC-695: called past the wire, a repeated upload or a repeated stored file is refused (400) with NOTHING written', async () => {
648
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
649
+ const t = backend()
650
+ const [first, second] = [await declare(t), await declare(t)]
651
+ const file = await storeFile(t, 400, 'image/png')
652
+ const another = await storeFile(t, 400, 'image/png') // the same declared bytes, a second stored file
653
+ for (const repeat of [another, file]) {
654
+ const sameUpload = await claimDirect(t, [
655
+ { uploadId: first, storageId: file },
656
+ { uploadId: first, storageId: repeat }, // the one intent, the same file or ANOTHER file it would also pass for
657
+ ])
658
+ expect(sameUpload).toEqual({ ok: false, status: 400, message: 'Claim 2 names an upload an earlier claim of this submission names.' })
659
+ }
660
+ const sameFile = await claimDirect(t, [
661
+ { uploadId: first, storageId: file },
662
+ { uploadId: second, storageId: file },
663
+ ])
664
+ expect(sameFile).toEqual({ ok: false, status: 400, message: 'Claim 2 names a stored file an earlier claim of this submission names.' })
665
+ expect(await bindings(t)).toEqual([]) // deferred binds without the repeat check: two bindings, or one overwritten
666
+ expect(await intents(t)).toEqual({ [first]: 'issued', [second]: 'issued' })
667
+ expect([await stored(t, file), await stored(t, another)]).toEqual([true, true])
668
+ // Control: each claim once binds.
669
+ expect(await claimDirect(t, [{ uploadId: first, storageId: file }])).toEqual({
670
+ ok: true,
671
+ files: [{ storageId: file, field: 'cv', contentType: 'image/png', size: 400 }],
672
+ })
673
+ expect(await bindings(t)).toEqual([{ storageId: file, uploadId: first }])
674
+ })
675
+
676
+ it('EC-695: no claims is an empty success that writes nothing', async () => {
677
+ const t = backend()
678
+ expect(await claimDirect(t, [])).toEqual({ ok: true, files: [] })
679
+ expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).length)).toBe(0)
680
+ })
604
681
  })
605
682
 
606
683
  describe('the site’s route → the receiver, end to end (EC-617)', () => {
package/convex/ingest.ts CHANGED
@@ -41,10 +41,10 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
41
41
  * analytics addon must fail visibly (EC-618's counsel gate relies on it);
42
42
  * 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
43
43
  * — the bytes go browser → storage, cross-origin by design; the submission that follows
44
- * claims the stored file with {@link claimIngestUpload}, which checks that the deployment
45
- * stored it for THIS intent (EC-671) — its bytes the ones the upload request declared by
46
- * SHA-256 (EC-685) — and that it matches the declared size and type, and binds it;
47
- * {@link releaseIngestUploads} is the only way such bytes are deleted.
44
+ * claims its stored files with {@link claimIngestUploads}, which checks that the deployment
45
+ * stored each for ITS intent (EC-671) — its bytes the ones the upload request declared by
46
+ * SHA-256 (EC-685) — and that it matches the declared size and type, and binds them all or
47
+ * none (EC-695); {@link releaseIngestUploads} is the only way such bytes are deleted.
48
48
  *
49
49
  * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
50
50
  * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
@@ -180,8 +180,8 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
180
180
  // --- Submission-scoped uploads -------------------------------------------------------------
181
181
  //
182
182
  // The receiver issues a storage URL only for an upload a handler ACCEPTED and RECORDED as an
183
- // intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
184
- // stored file against the intent. Abuse is bounded by: the route's limiter and bot gate on every
183
+ // intent (`recordIngestUpload`); a submission CLAIMS its uploads (`claimIngestUploads`), which
184
+ // checks each stored file against its intent. Abuse is bounded by: the route's limiter and bot gate on every
185
185
  // URL issued, `INGEST_MAX_UPLOAD_BYTES` per file, the URL's one-hour life, the post-upload check
186
186
  // (an over-size or mistyped file is refused, never attached), and the unclaimed intents an addon can
187
187
  // list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
@@ -191,7 +191,7 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
191
191
  // EC-671 — ownership. The storage id a claim names is the VISITOR's word: nothing at intent
192
192
  // time can record it (`generateUploadUrl` takes no argument; the id reaches the uploader
193
193
  // alone), and an asset's storage id is public on the delivery snapshot. So a claim binds a
194
- // file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
194
+ // file only when the deployment's own facts say it is this upload's ({@link claimIngestUploads}),
195
195
  // it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
196
196
  // which deletes only what a claim bound.
197
197
  //
@@ -201,6 +201,14 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
201
201
  // the deployment computed at store time (`_storage.sha256`) to equal it — A can bind B's bytes
202
202
  // only by predicting them. Ownership is also kept true when a delete spends it: release keeps
203
203
  // a bound file an asset references now, and `assets:deleteBlob` keeps bytes a binding holds.
204
+ //
205
+ // EC-695 — a refusal never deletes. Causation holds only for bytes an attacker cannot predict:
206
+ // a deterministic file can be declared by anyone holding an intent open before it is stored
207
+ // (the RESIDUAL, `claimableFile`). So a submission's claims are judged ALL before any is bound
208
+ // (`claimIngestUploads`): a refused submission binds nothing and has nothing to release, so a
209
+ // substituted file is never deleted by a rollback. The substitution itself remains — A's
210
+ // submission can hold B's predictable file, and B's claim is refused visibly as bound — and is
211
+ // closed only by the engine receiving the bytes (a new route: EC-304 Phase B, the owner's).
204
212
 
205
213
  /** The upload intent — an engine-internal record kind (no public functions). */
206
214
  export const ingestUploadRecordKind = defineRecordKind({
@@ -225,7 +233,7 @@ export const ingestUploadRecordKind = defineRecordKind({
225
233
  /**
226
234
  * EC-671 — the engine's ONE ownership fact for ingest bytes: a record per stored file a claim
227
235
  * bound to its intent, keyed BY the storage id (`recordId`), so a file is bound at most once
228
- * and "is it bound?" is one indexed read. Written only by {@link claimIngestUpload}, in the
236
+ * and "is it bound?" is one indexed read. Written only by {@link claimIngestUploads}, in the
229
237
  * claim's transaction; read by the claim (a file another claim bound is refused) and by
230
238
  * {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
231
239
  */
@@ -293,10 +301,22 @@ function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
293
301
  }
294
302
  }
295
303
 
296
- /** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
297
- export type IngestUploadClaimResult =
298
- | { ok: true; storageId: string; field: string; contentType: string; size: number }
299
- | { ok: false; status: 409 | 413 | 415 | 422; message: string }
304
+ /** One stored file a submission's claim bound: the deployment's id for it, and the intent's facts. */
305
+ export interface IngestClaimedUpload {
306
+ storageId: string
307
+ field: string
308
+ contentType: string
309
+ size: number
310
+ }
311
+
312
+ /** A claim's refusal, for the handler to return as it is. */
313
+ export type IngestUploadClaimRefusal = { ok: false; status: 400 | 409 | 413 | 415 | 422; message: string }
314
+
315
+ /** What a submission's claim yields: every file it bound, in claim order — or the one refusal, with nothing bound. */
316
+ export type IngestUploadsClaimResult = { ok: true; files: IngestClaimedUpload[] } | IngestUploadClaimRefusal
317
+
318
+ /** A claim the judge accepted — its binding is written only once every claim of the submission is accepted. */
319
+ type AcceptedClaim = IngestClaimedUpload & { uploadId: string }
300
320
 
301
321
  /**
302
322
  * EC-671 — whether the deployment's own facts say a stored file is this intent's to claim. The
@@ -314,7 +334,10 @@ export type IngestUploadClaimResult =
314
334
  * when it stored them (`_storage.sha256`) equals the intent's `sha256`, through the ONE
315
335
  * comparator (`compareIngestUploadSha256`, which reads the stored digest by its form). Checks
316
336
  * 1–3 are eligibility; this one is causation: with intents A and B open, bytes stored
317
- * through B's URL pass 1–3 for A too, and fail 4 unless A predicted them.
337
+ * through B's URL pass 1–3 for A too, and fail 4 unless A predicted them. RESIDUAL (named,
338
+ * not closed): bytes A CAN predict (a template, a generated file) pass 4 for A as well —
339
+ * content equality is not uploader identity. EC-695 keeps that non-destructive (a refusal
340
+ * binds nothing, {@link claimIngestUploads}); only the engine receiving the bytes closes it.
318
341
  *
319
342
  * A stored digest in neither form (`unreadable`) is its own answer — a deployment fault, the
320
343
  * same for every file, refused visibly and never read as a match. It is read first, so it
@@ -350,24 +373,74 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
350
373
  }
351
374
 
352
375
  /**
353
- * Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
354
- * belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
355
- * intent's ({@link claimableFile}, EC-671 + EC-685: its bytes the declared ones) and match it
356
- * (size ≤ declared, same content type).
357
- * A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
358
- * deletion reads ({@link releaseIngestUploads}).
376
+ * EC-695 — claim ALL of a submission's uploads for `target`, all or nothing: the engine's ONE
377
+ * claim primitive. Every claim is JUDGED first ({@link judgeIngestUploadClaim}); a binding is
378
+ * written only once every claim passed, in this one transaction. So a submission with one
379
+ * refused claim binds NOTHING, and there is nothing to release: no caller ever deletes bytes
380
+ * because a claim failed. (Before EC-695 a claim bound as it went and forms' rollback released
381
+ * what it had bound; with a predictable file — {@link claimableFile}'s residual — "bound in this
382
+ * call" could be ANOTHER intent's file, and a deliberately mismatching second claim made the
383
+ * rollback delete it.)
384
+ *
385
+ * Each claim's intent must exist, be unclaimed, belong to `target`, be younger than
386
+ * `INGEST_UPLOAD_TTL_MS`; its stored file must be this intent's ({@link claimableFile}, EC-671 +
387
+ * EC-685: its bytes the declared ones) and match it (size ≤ declared, same content type). A
388
+ * claimed file is BOUND to its intent ({@link ingestBindingRecordKind}) — the fact every deletion
389
+ * reads ({@link releaseIngestUploads}) — and handed back in claim order.
359
390
  *
360
- * A refusal marks the intent `rejected` (recording the id it named) and deletes NOTHING
391
+ * Because the bindings wait for the whole verdict, the checks a binding would answer for a LATER
392
+ * claim of the same submission are answered from the claims already judged: an upload, or a
393
+ * stored file (by the deployment's own id), that an earlier claim named is refused (400, nothing
394
+ * written). The wire refuses the same repeats by their spelling first (`@elytracms/core/ingest`).
395
+ *
396
+ * The first refusal is returned as it is. If the claim's ownership or declaration checks refused
397
+ * it, its intent is marked `rejected` (recording the id it named) — and nothing is deleted
361
398
  * (EC-671): the named id is the visitor's word, and even a file that passed every ownership
362
399
  * check may belong to someone the engine cannot see. A refused upload's bytes stay where the
363
- * uploader put them — the same residue as an upload never claimed.
400
+ * uploader put them — the same residue as an upload never claimed. The claims judged before it
401
+ * were never bound, so their intents stay `issued` and a corrected submission can claim them.
402
+ *
403
+ * A handler calls this ONCE per submission, after every check of its own that can refuse: a
404
+ * mutation that RETURNS commits its writes (a claim made before a later refusal stays bound with
405
+ * no row to hold it); one that THROWS commits nothing.
406
+ */
407
+ export async function claimIngestUploads(
408
+ ctx: MutationCtx,
409
+ claims: readonly IngestUploadClaim[],
410
+ target: string,
411
+ receivedAt: string,
412
+ ): Promise<IngestUploadsClaimResult> {
413
+ const accepted: AcceptedClaim[] = []
414
+ for (const [index, claim] of claims.entries()) {
415
+ const verdict = await judgeIngestUploadClaim(ctx, claim, target, receivedAt, index, accepted)
416
+ if (!verdict.ok) return verdict
417
+ accepted.push(verdict.claim)
418
+ }
419
+ const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
420
+ for (const claim of accepted) {
421
+ await saveRecord(ctx, ingestBindingRecordKind, claim.storageId, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
422
+ await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
423
+ }
424
+ return { ok: true, files: accepted.map(({ storageId, field, contentType, size }) => ({ storageId, field, contentType, size })) }
425
+ }
426
+
427
+ /**
428
+ * Judge ONE claim of a submission, given the claims of the same submission already accepted
429
+ * (`earlier`, not yet bound): its verdict, or its refusal. It writes only a refusal's `rejected`
430
+ * mark — never a binding ({@link claimIngestUploads} writes those, once every claim passed).
364
431
  */
365
- export async function claimIngestUpload(
432
+ async function judgeIngestUploadClaim(
366
433
  ctx: MutationCtx,
367
434
  claim: IngestUploadClaim,
368
435
  target: string,
369
436
  receivedAt: string,
370
- ): Promise<IngestUploadClaimResult> {
437
+ index: number,
438
+ earlier: readonly AcceptedClaim[],
439
+ ): Promise<{ ok: true; claim: AcceptedClaim } | IngestUploadClaimRefusal> {
440
+ // The intent's state check, for an intent an earlier claim of THIS submission already took.
441
+ if (earlier.some((accepted) => accepted.uploadId === claim.uploadId)) {
442
+ return { ok: false, status: 400, message: `Claim ${index + 1} names an upload an earlier claim of this submission names.` }
443
+ }
371
444
  const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
372
445
  const columns = intent?.data as
373
446
  | { target: string; field: string; contentType: string; size: number; sha256?: string; state: string; issuedAt: string }
@@ -399,7 +472,12 @@ export async function claimIngestUpload(
399
472
  const file = await ctx.db.system.get(storageId).catch(() => null)
400
473
  const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
401
474
  if (!file) return { ok: false, status: 422, message: `No stored file “${claim.storageId}”.` }
402
- const reject = async (refusal: { status: 413 | 415 | 422; message: string }): Promise<IngestUploadClaimResult> => {
475
+ // Check (2) of {@link claimableFile}, for a file an earlier claim of THIS submission already
476
+ // took — by the deployment's own id (`file._id`), so no second spelling of one id slips by.
477
+ if (earlier.some((accepted) => accepted.storageId === file._id)) {
478
+ return { ok: false, status: 400, message: `Claim ${index + 1} names a stored file an earlier claim of this submission names.` }
479
+ }
480
+ const reject = async (refusal: { status: 413 | 415 | 422; message: string }): Promise<IngestUploadClaimRefusal> => {
403
481
  await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
404
482
  return { ok: false, ...refusal }
405
483
  }
@@ -421,11 +499,12 @@ export async function claimIngestUpload(
421
499
  if ((file.contentType ?? '') !== columns.contentType) {
422
500
  return reject({ status: 415, message: 'The stored file is not of the declared type.' })
423
501
  }
424
- // Bound — and handed back — under the deployment's own id for the file (`file._id`), the
425
- // spelling checks (2) and (3) read, never the string the visitor sent.
426
- await saveRecord(ctx, ingestBindingRecordKind, file._id, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
427
- await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
428
- return { ok: true, storageId: file._id, field: columns.field, contentType: columns.contentType, size: file.size }
502
+ // Accepted — to be bound, and handed back, under the deployment's own id for the file
503
+ // (`file._id`), the spelling checks (2) and (3) read, never the string the visitor sent.
504
+ return {
505
+ ok: true,
506
+ claim: { uploadId: claim.uploadId, storageId: file._id, field: columns.field, contentType: columns.contentType, size: file.size },
507
+ }
429
508
  }
430
509
 
431
510
  /**
@@ -441,8 +520,8 @@ export async function claimIngestUpload(
441
520
  * asset's own delete removes them later. Read in this transaction, so no asset can acquire
442
521
  * the file between the check and the delete.
443
522
  *
444
- * Call it for a refused submission's already-claimed files and when removing a row that holds
445
- * claimed files. The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
523
+ * Call it when removing a row that holds claimed files — never for a refused submission, which
524
+ * bound nothing ({@link claimIngestUploads}, EC-695). The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
446
525
  * ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
447
526
  * transaction.
448
527
  */
@@ -0,0 +1,201 @@
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 { fieldPath, nodePath, nodePropPath, subFieldPath } from '@elytracms/core/cms-core'
7
+ import type { CollectionDef, ValuePath } from '@elytracms/core/cms-core'
8
+ import { createOperations } from '@elytracms/core/operations'
9
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
10
+ import schema from './schema'
11
+
12
+ // convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
13
+ // fake timers keep it queued.
14
+ beforeEach(() => {
15
+ vi.useFakeTimers()
16
+ vi.setSystemTime('2026-10-28T00:00:00.000Z')
17
+ })
18
+ afterEach(() => {
19
+ vi.useRealTimers()
20
+ })
21
+
22
+ /**
23
+ * EC-692 (Codex round 5 F29 — `tasks/codex-review-2026-10-28-r5/write-boundary-contracts.ts:41-80` run
24
+ * by `write-boundaries.convex.test.ts`, copied here and INVERTED; widened by the wave-0100 critic) on
25
+ * Convex, over the REAL `cms.ts` mutations. Two entry points:
26
+ *
27
+ * 1. Through operations (the production `ConvexPersistenceAdapter`): on the base a segment with one extra
28
+ * key passed EC-684's raw refusal, the parse stripped the key, and the mutation applied the `__proto__`
29
+ * segment — `ok: true` with the value lost, and `group → __proto__ → marker` wrote the marker to
30
+ * `Object.prototype` in the process applying it.
31
+ * 2. A DIRECT authenticated call of `cms:patchDocument` / `cms:publishDocument` (`patches: v.any()`, no
32
+ * parse, no `runAny`): on the base a `locale` or `sub` segment `__proto__` polluted `Object.prototype`
33
+ * with NO extra key, a `field` segment reported `ok` and lost the value, and an `insert` at slot
34
+ * `__proto__` threw out of the mutation. The locale "control" of the review holds only through
35
+ * operations (zod's locale schema); through the mutation it was a counterexample.
36
+ *
37
+ * Now the applier judges every raw patch by the one path grammar before it follows a name, in this
38
+ * store as in memory, and every walk reads and writes own properties only.
39
+ */
40
+
41
+ const modules = {
42
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
43
+ './_generated/api.ts': async () => ({}),
44
+ }
45
+ const P = 'site'
46
+ const ref = { collection: 'post', id: 'p' }
47
+ const STAMP = '2026-10-28T00:00:00.000Z'
48
+ const proto = Object.prototype as unknown as Record<string, unknown>
49
+ const patchDocument = makeFunctionReference<'mutation'>('cms:patchDocument')
50
+ const publishDocument = makeFunctionReference<'mutation'>('cms:publishDocument')
51
+
52
+ const collections: CollectionDef[] = [
53
+ {
54
+ id: 'post',
55
+ kind: 'document',
56
+ fields: [
57
+ { name: 'title', type: 'text' },
58
+ { name: 'group', type: 'object', multiple: false, fields: [] },
59
+ { name: 'body', type: 'blocks' },
60
+ ],
61
+ },
62
+ ]
63
+ const card = (id: string) => ({ type: 'elytra/component' as const, id, componentId: 'Card', props: { title: { value: id } } })
64
+
65
+ async function world() {
66
+ const t = convexTest(schema, modules)
67
+ await t.run(async (ctx) => {
68
+ await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: STAMP, updatedAt: STAMP })
69
+ })
70
+ const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
71
+ const client = {
72
+ query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
73
+ mutation: (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => admin.mutation(fn, args ?? {}),
74
+ } as unknown as ConvexHttpClient
75
+ const adapter = new ConvexPersistenceAdapter(client, P)
76
+ const ops = createOperations({ adapter, collections: () => collections, validateDocument: () => [] })
77
+ const created = await ops.documents.replace({
78
+ projectId: P,
79
+ document: { ...ref, values: { title: 'KEEP', group: {}, body: { version: 2, content: [card('c')] } } },
80
+ precondition: { ifAbsent: true },
81
+ })
82
+ expect(created.ok).toBe(true)
83
+ const stored = async () => (await adapter.cms.getDocument(P, ref))
84
+ /** The mutation called directly, as an authenticated client may: raw patches, no operations layer. */
85
+ const direct = (patches: unknown[]) =>
86
+ admin.mutation(patchDocument, { collection: ref.collection, docId: ref.id, patches, baseRevision: 1, at: STAMP }) as Promise<{
87
+ status: string
88
+ appliedCount?: number
89
+ refused?: { reason: string }[]
90
+ }>
91
+ return { t, admin, adapter, ops, stored, direct }
92
+ }
93
+
94
+ /** Assert `Object.prototype` carries no `marker` after `body` — and clean up in `finally`. */
95
+ async function prototypeUnchanged(marker: string, body: () => Promise<void>): Promise<void> {
96
+ expect(Object.hasOwn(proto, marker)).toBe(false)
97
+ try {
98
+ await body()
99
+ expect(Object.hasOwn(proto, marker)).toBe(false)
100
+ } finally {
101
+ delete proto[marker]
102
+ }
103
+ }
104
+
105
+ const withExtra = (path: ValuePath): ValuePath => path.map((segment) => ({ ...segment, extra: true }))
106
+
107
+ describe('EC-692 F29 inverted on Convex — through operations', () => {
108
+ const segments: [string, ValuePath][] = [
109
+ ['field', fieldPath('__proto__')],
110
+ ['sub', subFieldPath('group', undefined, '__proto__')],
111
+ ['locale', fieldPath('round5UnusedField', '__proto__')],
112
+ ['prop', nodePropPath('body', undefined, 'c', '__proto__')],
113
+ ]
114
+ for (const [name, path] of segments) {
115
+ it(`${name}: refused as validation with the extra key (never stripped) and without it`, async () => {
116
+ const { ops, stored } = await world()
117
+ const send = (at: ValuePath) => ops.documents.patch({ projectId: P, ref, baseRevision: 1, patches: [{ op: 'unset', path: at }] })
118
+ expect(await send(path)).toMatchObject({ ok: false, error: { code: 'validation', issues: [expect.objectContaining({ code: 'prototype-key' })] } })
119
+ expect(await send(withExtra(path))).toMatchObject({ ok: false, error: { code: 'validation' } })
120
+ expect((await stored()).revision).toBe(1)
121
+ })
122
+ }
123
+
124
+ it('slot: an insert at slot "__proto__" is refused as validation — CONTROL: an ordinary slot inserts', async () => {
125
+ const { ops, stored } = await world()
126
+ const insert = (slot: string) =>
127
+ ops.documents.patch({ projectId: P, ref, baseRevision: 1, patches: [{ op: 'insert', at: { parent: nodePath('body', undefined, 'c'), slot, index: 0 }, node: card('n') }] })
128
+ expect(await insert('__proto__')).toMatchObject({ ok: false, error: { code: 'validation' } })
129
+ expect((await stored()).revision).toBe(1)
130
+ expect((await insert('items')).ok).toBe(true)
131
+ })
132
+
133
+ it('the prototype trace: group → __proto__ (extra key) → marker is refused; Object.prototype unchanged', async () => {
134
+ const { ops, stored } = await world()
135
+ const marker = 'ec692ConvexOperationsMarker'
136
+ await prototypeUnchanged(marker, async () => {
137
+ const result = await ops.documents.patch({
138
+ projectId: P,
139
+ ref,
140
+ baseRevision: 1,
141
+ patches: [{ op: 'set', path: [...fieldPath('group'), { kind: 'sub', name: '__proto__', extra: true } as never, { kind: 'sub', name: marker }], value: 'PROBE' }],
142
+ })
143
+ expect(result).toMatchObject({ ok: false, error: { code: 'validation' } })
144
+ expect((await stored()).revision).toBe(1)
145
+ })
146
+ })
147
+
148
+ it('CONTROL — a sub-field named "constructor" applies, as an own key', async () => {
149
+ const { ops, stored } = await world()
150
+ const result = await ops.documents.patch({ projectId: P, ref, baseRevision: 1, patches: [{ op: 'set', path: subFieldPath('group', undefined, 'constructor', 'x'), value: 1 }] })
151
+ expect(result.ok).toBe(true)
152
+ expect((await stored()).document.values.group).toEqual({ constructor: { x: 1 } })
153
+ })
154
+ })
155
+
156
+ describe('EC-692 on Convex — a DIRECT call of cms:patchDocument (raw patches, no parse, no runAny)', () => {
157
+ const marker = 'ec692DirectMarker'
158
+ const raw: [string, unknown][] = [
159
+ ['locale (no extra key)', { op: 'set', path: [{ kind: 'field', name: marker }, { kind: 'locale', locale: '__proto__' }], value: 'POLLUTED' }],
160
+ ['sub (no extra key)', { op: 'set', path: subFieldPath('group', undefined, '__proto__', marker), value: 'POLLUTED' }],
161
+ ['field (no extra key)', { op: 'set', path: fieldPath('__proto__'), value: { [marker]: 'POLLUTED' } }],
162
+ ['prop (no extra key)', { op: 'set', path: nodePropPath('body', undefined, 'c', '__proto__'), value: 'x' }],
163
+ ['slot', { op: 'insert', at: { parent: nodePath('body', undefined, 'c'), slot: '__proto__', index: 0 }, node: card('n') }],
164
+ ['an extra segment key', { op: 'set', path: [{ kind: 'field', name: 'title', extra: true }], value: 'x' }],
165
+ ['a malformed patch', { op: 'set', path: [null], value: 'x' }],
166
+ ]
167
+ for (const [name, patch] of raw) {
168
+ it(`${name}: refused as invalid, nothing written, Object.prototype unchanged`, async () => {
169
+ const { direct, stored } = await world()
170
+ await prototypeUnchanged(marker, async () => {
171
+ const outcome = await direct([patch])
172
+ expect(outcome).toMatchObject({ status: 'conflict', appliedCount: 0, refused: [{ reason: 'invalid' }] })
173
+ expect((await stored()).revision).toBe(1)
174
+ expect((await stored()).document.values.title).toBe('KEEP')
175
+ })
176
+ })
177
+ }
178
+
179
+ it('CONTROL — the same mutation applies an ordinary patch and a real sub-field named "constructor"', async () => {
180
+ const { direct, stored } = await world()
181
+ expect(await direct([{ op: 'set', path: subFieldPath('group', undefined, 'constructor', 'x'), value: 1 }, { op: 'set', path: fieldPath('title'), value: 'NEW' }])).toMatchObject({ status: 'ok' })
182
+ const after = await stored()
183
+ expect(after.revision).toBe(2)
184
+ expect(after.document.values).toMatchObject({ title: 'NEW', group: { constructor: { x: 1 } } })
185
+ })
186
+
187
+ it('cms:publishDocument applies its hidden-clear patches by the same grammar: a "__proto__" locale refuses the publish, unpolluted', async () => {
188
+ const { admin, stored } = await world()
189
+ await prototypeUnchanged(marker, async () => {
190
+ const outcome = (await admin.mutation(publishDocument, {
191
+ collection: ref.collection,
192
+ docId: ref.id,
193
+ expectedRevision: 1,
194
+ at: STAMP,
195
+ patches: [{ op: 'set', path: [{ kind: 'field', name: marker }, { kind: 'locale', locale: '__proto__' }], value: 'POLLUTED' }],
196
+ })) as { status: string }
197
+ expect(outcome.status).toBe('conflict')
198
+ expect((await stored()).publishedVersion).toBeUndefined()
199
+ })
200
+ })
201
+ })