@elytracms/studio 0.0.97 → 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 (107) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +269 -16
  4. package/convex/ingest.ts +238 -47
  5. package/convex/path-segment-refusal.test.ts +201 -0
  6. package/convex/prototype-key.test.ts +92 -0
  7. package/convex/records-column-transform.convex.test.ts +147 -0
  8. package/convex/records-zod-refinements.convex.test.ts +94 -0
  9. package/convex/schema.ts +1 -1
  10. package/dist/addon.d.ts +5 -3
  11. package/dist/addon.js +4 -3
  12. package/dist/addon.js.map +1 -1
  13. package/dist/components/app-views/default-modules.d.ts +10 -3
  14. package/dist/components/app-views/default-modules.js +5 -1
  15. package/dist/components/app-views/default-modules.js.map +1 -1
  16. package/dist/components/app-views/module.d.ts +15 -8
  17. package/dist/components/app-views/module.js.map +1 -1
  18. package/dist/components/builder/reuse-context.d.ts +29 -1
  19. package/dist/components/builder/reuse-context.js +58 -12
  20. package/dist/components/builder/reuse-context.js.map +1 -1
  21. package/dist/components/cms/cms-store.d.ts +5 -6
  22. package/dist/components/cms/cms-store.js +12 -24
  23. package/dist/components/cms/cms-store.js.map +1 -1
  24. package/dist/components/cms/field-control.d.ts +12 -1
  25. package/dist/components/cms/field-control.js +31 -6
  26. package/dist/components/cms/field-control.js.map +1 -1
  27. package/dist/components/cms/import-batch.d.ts +23 -0
  28. package/dist/components/cms/import-batch.js +43 -0
  29. package/dist/components/cms/import-batch.js.map +1 -0
  30. package/dist/components/cms/rich-text/editor.js +13 -10
  31. package/dist/components/cms/rich-text/editor.js.map +1 -1
  32. package/dist/components/cms/rich-text/value.d.ts +9 -0
  33. package/dist/components/cms/rich-text/value.js +13 -0
  34. package/dist/components/cms/rich-text/value.js.map +1 -1
  35. package/dist/components/cms/use-document-draft.d.ts +39 -3
  36. package/dist/components/cms/use-document-draft.js +46 -16
  37. package/dist/components/cms/use-document-draft.js.map +1 -1
  38. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  39. package/dist/components/cms/use-publish-controller.js +17 -3
  40. package/dist/components/cms/use-publish-controller.js.map +1 -1
  41. package/dist/components/cms/versions-pane.js +8 -5
  42. package/dist/components/cms/versions-pane.js.map +1 -1
  43. package/dist/components/delivery-health-banner.d.ts +34 -16
  44. package/dist/components/delivery-health-banner.js +59 -27
  45. package/dist/components/delivery-health-banner.js.map +1 -1
  46. package/dist/components/issue-severity.d.ts +3 -2
  47. package/dist/components/issue-severity.js.map +1 -1
  48. package/dist/components/nav-issues-registry.d.ts +39 -24
  49. package/dist/components/nav-issues-registry.js +50 -21
  50. package/dist/components/nav-issues-registry.js.map +1 -1
  51. package/dist/components/nav-issues.d.ts +23 -50
  52. package/dist/components/nav-issues.js +31 -59
  53. package/dist/components/nav-issues.js.map +1 -1
  54. package/dist/components/notifications/notice-center.d.ts +61 -0
  55. package/dist/components/notifications/notice-center.js +119 -0
  56. package/dist/components/notifications/notice-center.js.map +1 -0
  57. package/dist/components/notifications/notice-model.d.ts +137 -0
  58. package/dist/components/notifications/notice-model.js +107 -0
  59. package/dist/components/notifications/notice-model.js.map +1 -0
  60. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  61. package/dist/components/notifications/outcome-notice.js +34 -0
  62. package/dist/components/notifications/outcome-notice.js.map +1 -0
  63. package/dist/components/notifications/shell-findings.d.ts +36 -0
  64. package/dist/components/notifications/shell-findings.js +140 -0
  65. package/dist/components/notifications/shell-findings.js.map +1 -0
  66. package/dist/components/rail-view-source.js +5 -0
  67. package/dist/components/rail-view-source.js.map +1 -1
  68. package/dist/components/records/record-lens.js.map +1 -1
  69. package/dist/components/records/record-rail.d.ts +10 -2
  70. package/dist/components/records/record-rail.js +74 -4
  71. package/dist/components/records/record-rail.js.map +1 -1
  72. package/dist/components/section-nav.d.ts +15 -3
  73. package/dist/components/section-nav.js +39 -147
  74. package/dist/components/section-nav.js.map +1 -1
  75. package/dist/components/studio-project-screen.js.map +1 -1
  76. package/dist/components/studio-shell.js +8 -3
  77. package/dist/components/studio-shell.js.map +1 -1
  78. package/dist/components/use-narrow-viewport.d.ts +11 -0
  79. package/dist/components/use-narrow-viewport.js +25 -0
  80. package/dist/components/use-narrow-viewport.js.map +1 -0
  81. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  82. package/dist/components/users/role-policy-drift-issues.js +17 -8
  83. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  84. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  85. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  86. package/dist/lib/engine-version.d.ts +1 -1
  87. package/dist/lib/engine-version.js +1 -1
  88. package/dist/lib/engine-version.js.map +1 -1
  89. package/dist/lib/i18n/messages.de.js +24 -3
  90. package/dist/lib/i18n/messages.de.js.map +1 -1
  91. package/dist/lib/i18n/messages.en.d.ts +22 -2
  92. package/dist/lib/i18n/messages.en.js +27 -4
  93. package/dist/lib/i18n/messages.en.js.map +1 -1
  94. package/dist/lib/notices/toast-store.d.ts +61 -0
  95. package/dist/lib/notices/toast-store.js +116 -0
  96. package/dist/lib/notices/toast-store.js.map +1 -0
  97. package/dist/lib/operations/cms-persistence.d.ts +32 -7
  98. package/dist/lib/operations/cms-persistence.js +511 -71
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +61 -30
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +1 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/dist/ui/toast.d.ts +24 -16
  105. package/dist/ui/toast.js +23 -32
  106. package/dist/ui/toast.js.map +1 -1
  107. package/package.json +4 -3
package/convex/ingest.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { httpActionGeneric, makeFunctionReference } from 'convex/server'
2
- import type { FunctionReference, PublicHttpAction } from 'convex/server'
2
+ import type { FunctionReference, GenericActionCtx, GenericDataModel, PublicHttpAction } from 'convex/server'
3
3
  import { v } from 'convex/values'
4
4
  import type { GenericId } from 'convex/values'
5
5
  import { z } from 'zod'
@@ -7,7 +7,9 @@ import {
7
7
  INGEST_MAX_BODY_BYTES,
8
8
  INGEST_MAX_UPLOAD_BYTES,
9
9
  INGEST_SIGNATURE_HEADER,
10
+ INGEST_UPLOAD_SHA256,
10
11
  INGEST_UPLOAD_TTL_MS,
12
+ compareIngestUploadSha256,
11
13
  ingestPayloadSchema,
12
14
  readIngestReceiverBody,
13
15
  verifyIngestSignature,
@@ -39,23 +41,30 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
39
41
  * analytics addon must fail visibly (EC-618's counsel gate relies on it);
40
42
  * 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
41
43
  * — the bytes go browser → storage, cross-origin by design; the submission that follows
42
- * claims the stored file with {@link claimIngestUpload}, which checks that the deployment
43
- * stored it for THIS intent (EC-671) and that it matches what the upload request declared,
44
- * and binds it; {@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.
45
48
  *
46
49
  * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
47
50
  * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
48
51
  * submission: formsIngest } })`), exported by the addon's `./convex` entry.
49
52
  */
50
53
 
51
- /** What an ingest handler receives: the verified, parsed payload and the receipt time. */
52
- export const ingestHandlerArgs = { payload: v.any(), receivedAt: v.string() }
54
+ /**
55
+ * What an ingest handler receives: the verified, parsed payload, the receipt time, and — only
56
+ * when the receiver RETRIES the request (EC-618) — which attempt this is. `attempt` is absent on
57
+ * the first run (so a handler declared with the first two arguments alone runs unchanged) and
58
+ * `2…INGEST_HANDLER_ATTEMPTS` on a retry; the payload and `receivedAt` are the same on every
59
+ * attempt. A handler that does not care ignores it.
60
+ */
61
+ export const ingestHandlerArgs = { payload: v.any(), receivedAt: v.string(), attempt: v.optional(v.number()) }
53
62
 
54
63
  /** An addon's ingest handler: an INTERNAL mutation (never client-callable). */
55
64
  export type IngestHandlerRef = FunctionReference<
56
65
  'mutation',
57
66
  'internal',
58
- { payload: IngestPayload; receivedAt: string },
67
+ { payload: IngestPayload; receivedAt: string; attempt?: number },
59
68
  IngestHandlerResult
60
69
  >
61
70
 
@@ -97,6 +106,32 @@ function utf8Length(text: string): number {
97
106
  return new TextEncoder().encode(text).byteLength
98
107
  }
99
108
 
109
+ /**
110
+ * How many times the receiver runs a handler whose mutation THREW before the failure surfaces
111
+ * (EC-618). A Convex mutation cannot observe its own optimistic-concurrency conflict — Convex
112
+ * re-executes it and reports exhaustion only to the caller, this `runMutation` — so the receiver
113
+ * is where a request gets another attempt (`attempt: 2, 3` — see {@link ingestHandlerArgs}).
114
+ * A thrown mutation committed nothing, so a retry never counts twice. A handler's REFUSAL
115
+ * (`{ ok: false }`) is an answer, never retried.
116
+ */
117
+ export const INGEST_HANDLER_ATTEMPTS = 3
118
+
119
+ async function runIngestHandler(
120
+ ctx: Pick<GenericActionCtx<GenericDataModel>, 'runMutation'>,
121
+ handler: IngestHandlerRef,
122
+ payload: IngestPayload,
123
+ receivedAt: string,
124
+ ): Promise<IngestHandlerResult> {
125
+ for (let attempt = 1; ; attempt += 1) {
126
+ try {
127
+ // Attempt 1 passes exactly the two arguments every handler has always received.
128
+ return await ctx.runMutation(handler, attempt === 1 ? { payload, receivedAt } : { payload, receivedAt, attempt })
129
+ } catch (error) {
130
+ if (attempt >= INGEST_HANDLER_ATTEMPTS) throw error
131
+ }
132
+ }
133
+ }
134
+
100
135
  /** The receiver `httpAction` (see the module header). */
101
136
  export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction {
102
137
  return httpActionGeneric(async (ctx, request) => {
@@ -127,7 +162,7 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
127
162
  handled: false,
128
163
  })
129
164
  }
130
- const result = await ctx.runMutation(handler, { payload: body, receivedAt: new Date().toISOString() })
165
+ const result = await runIngestHandler(ctx, handler, body, new Date().toISOString())
131
166
  if (!result.ok) {
132
167
  return json(result.status, { error: result.message, ...(result.issues ? { issues: result.issues } : {}) })
133
168
  }
@@ -145,8 +180,8 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
145
180
  // --- Submission-scoped uploads -------------------------------------------------------------
146
181
  //
147
182
  // The receiver issues a storage URL only for an upload a handler ACCEPTED and RECORDED as an
148
- // intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
149
- // 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
150
185
  // URL issued, `INGEST_MAX_UPLOAD_BYTES` per file, the URL's one-hour life, the post-upload check
151
186
  // (an over-size or mistyped file is refused, never attached), and the unclaimed intents an addon can
152
187
  // list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
@@ -156,9 +191,24 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
156
191
  // EC-671 — ownership. The storage id a claim names is the VISITOR's word: nothing at intent
157
192
  // time can record it (`generateUploadUrl` takes no argument; the id reaches the uploader
158
193
  // alone), and an asset's storage id is public on the delivery snapshot. So a claim binds a
159
- // 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}),
160
195
  // it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
161
196
  // which deletes only what a claim bound.
197
+ //
198
+ // EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
199
+ // ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
200
+ // the intent DECLARES the bytes' SHA-256 before they exist, and the claim requires the digest
201
+ // the deployment computed at store time (`_storage.sha256`) to equal it — A can bind B's bytes
202
+ // only by predicting them. Ownership is also kept true when a delete spends it: release keeps
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).
162
212
 
163
213
  /** The upload intent — an engine-internal record kind (no public functions). */
164
214
  export const ingestUploadRecordKind = defineRecordKind({
@@ -171,6 +221,8 @@ export const ingestUploadRecordKind = defineRecordKind({
171
221
  field: z.string().min(1),
172
222
  contentType: z.string().min(1),
173
223
  size: z.number().int().positive(),
224
+ /** EC-685: the SHA-256 the upload declared for its bytes (lowercase hex) — what makes the claim causal. */
225
+ sha256: z.string().regex(INGEST_UPLOAD_SHA256),
174
226
  state: z.enum(['issued', 'claimed', 'rejected']),
175
227
  issuedAt: z.string().min(1),
176
228
  /** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
@@ -181,7 +233,7 @@ export const ingestUploadRecordKind = defineRecordKind({
181
233
  /**
182
234
  * EC-671 — the engine's ONE ownership fact for ingest bytes: a record per stored file a claim
183
235
  * bound to its intent, keyed BY the storage id (`recordId`), so a file is bound at most once
184
- * 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
185
237
  * claim's transaction; read by the claim (a file another claim bound is refused) and by
186
238
  * {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
187
239
  */
@@ -240,13 +292,31 @@ export async function recordIngestUpload(
240
292
  }
241
293
 
242
294
  function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
243
- return { target: payload.target, field: payload.field, contentType: payload.contentType, size: payload.size }
295
+ return {
296
+ target: payload.target,
297
+ field: payload.field,
298
+ contentType: payload.contentType,
299
+ size: payload.size,
300
+ sha256: payload.sha256,
301
+ }
302
+ }
303
+
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
244
310
  }
245
311
 
246
- /** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
247
- export type IngestUploadClaimResult =
248
- | { ok: true; storageId: string; field: string; contentType: string; size: number }
249
- | { ok: false; status: 409 | 413 | 415 | 422; message: string }
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 }
250
320
 
251
321
  /**
252
322
  * EC-671 — whether the deployment's own facts say a stored file is this intent's to claim. The
@@ -259,18 +329,33 @@ export type IngestUploadClaimResult =
259
329
  * before the intent would compare equal);
260
330
  * 2. no claim bound it already ({@link ingestBindingRecordKind});
261
331
  * 3. no asset row references it — `storageKey` or `thumb.storageKey` (an asset's storage id is
262
- * public on the delivery snapshot the moment its row exists).
332
+ * public on the delivery snapshot the moment its row exists);
333
+ * 4. EC-685: its bytes are the ones the intent DECLARED — the digest the deployment computed
334
+ * when it stored them (`_storage.sha256`) equals the intent's `sha256`, through the ONE
335
+ * comparator (`compareIngestUploadSha256`, which reads the stored digest by its form). Checks
336
+ * 1–3 are eligibility; this one is causation: with intents A and B open, bytes stored
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.
341
+ *
342
+ * A stored digest in neither form (`unreadable`) is its own answer — a deployment fault, the
343
+ * same for every file, refused visibly and never read as a match. It is read first, so it
344
+ * says nothing about which ownership check a named file would fail.
263
345
  *
264
346
  * Hiding `storageKey` would not be ownership; these are.
265
347
  */
266
348
  async function claimableFile(
267
349
  ctx: MutationCtx,
268
- file: { _id: GenericId<'_storage'>; _creationTime: number },
269
- intentCreatedAt: number,
270
- ): Promise<boolean> {
271
- if (file._creationTime < intentCreatedAt) return false
272
- if (await findRecordDoc(ctx, ingestBindingRecordKind, file._id)) return false
273
- return !(await assetReferencesStorage(ctx, file._id))
350
+ file: { _id: GenericId<'_storage'>; _creationTime: number; sha256: string },
351
+ intent: { createdAt: number; sha256: string },
352
+ ): Promise<'claimable' | 'not-this-intent' | 'digest-unreadable'> {
353
+ const digest = compareIngestUploadSha256(intent.sha256, file.sha256)
354
+ if (digest === 'unreadable') return 'digest-unreadable'
355
+ if (file._creationTime < intent.createdAt) return 'not-this-intent'
356
+ if (await findRecordDoc(ctx, ingestBindingRecordKind, file._id)) return 'not-this-intent'
357
+ if (await assetReferencesStorage(ctx, file._id)) return 'not-this-intent'
358
+ return digest === 'same' ? 'claimable' : 'not-this-intent'
274
359
  }
275
360
 
276
361
  /** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
@@ -288,26 +373,77 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
288
373
  }
289
374
 
290
375
  /**
291
- * Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
292
- * belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
293
- * intent's ({@link claimableFile}, EC-671) and match it (size ≤ declared, same content type).
294
- * A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
295
- * 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.
390
+ *
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`).
296
395
  *
297
- * A refusal marks the intent `rejected` (recording the id it named) and deletes NOTHING
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
298
398
  * (EC-671): the named id is the visitor's word, and even a file that passed every ownership
299
399
  * check may belong to someone the engine cannot see. A refused upload's bytes stay where the
300
- * 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).
301
431
  */
302
- export async function claimIngestUpload(
432
+ async function judgeIngestUploadClaim(
303
433
  ctx: MutationCtx,
304
434
  claim: IngestUploadClaim,
305
435
  target: string,
306
436
  receivedAt: string,
307
- ): 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
+ }
308
444
  const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
309
445
  const columns = intent?.data as
310
- | { target: string; field: string; contentType: string; size: number; state: string; issuedAt: string }
446
+ | { target: string; field: string; contentType: string; size: number; sha256?: string; state: string; issuedAt: string }
311
447
  | undefined
312
448
  if (!intent || !columns || columns.target !== target) {
313
449
  return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
@@ -316,36 +452,78 @@ export async function claimIngestUpload(
316
452
  if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
317
453
  return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
318
454
  }
455
+ // LEGACY (marked, EC-685): an intent issued before uploads declared their digest cannot be
456
+ // checked for causation. It is refused with its own reason, BEFORE any write: its row lacks
457
+ // the column the kind now requires, and whether the record rule accepts a write onto such a
458
+ // row is the record store's business (measured on 6310b7b4: it does; EC-682 rewrites that
459
+ // validator) — this branch never asks. Without it the claim would still be refused (the
460
+ // digest compares `different`), under the generic answer. Such intents are claimable for at
461
+ // most INGEST_UPLOAD_TTL_MS after the deploy (the expiry above refuses them first
462
+ // afterwards); delete this branch one release after EC-685 shipped.
463
+ const declared = columns.sha256
464
+ if (typeof declared !== 'string') {
465
+ return {
466
+ ok: false,
467
+ status: 422,
468
+ message: `Upload “${claim.uploadId}” was issued before uploads declared their SHA-256 — upload the file again.`,
469
+ }
470
+ }
319
471
  const storageId = claim.storageId as GenericId<'_storage'>
320
472
  const file = await ctx.db.system.get(storageId).catch(() => null)
321
473
  const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
322
474
  if (!file) return { ok: false, status: 422, message: `No stored file “${claim.storageId}”.` }
323
- 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> => {
324
481
  await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
325
482
  return { ok: false, ...refusal }
326
483
  }
327
484
  const intentRow = await ctx.db.get(intent._id)
328
- if (!intentRow || !(await claimableFile(ctx, file, intentRow._creationTime))) {
329
- // One answer for all three checks: which one failed is not the visitor's to learn.
485
+ const verdict = intentRow
486
+ ? await claimableFile(ctx, file, { createdAt: intentRow._creationTime, sha256: declared })
487
+ : 'not-this-intent'
488
+ if (verdict === 'digest-unreadable') {
489
+ return reject({
490
+ status: 422,
491
+ message: `The deployment's stored digest of “${claim.storageId}” is in neither form the claim reads (64 hex or 44 base64 characters), so the upload cannot be verified.`,
492
+ })
493
+ }
494
+ if (verdict !== 'claimable') {
495
+ // One answer for all four checks: which one failed is not the visitor's to learn.
330
496
  return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
331
497
  }
332
498
  if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
333
499
  if ((file.contentType ?? '') !== columns.contentType) {
334
500
  return reject({ status: 415, message: 'The stored file is not of the declared type.' })
335
501
  }
336
- // Bound — and handed back — under the deployment's own id for the file (`file._id`), the
337
- // spelling checks (2) and (3) read, never the string the visitor sent.
338
- await saveRecord(ctx, ingestBindingRecordKind, file._id, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
339
- await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
340
- 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
+ }
341
508
  }
342
509
 
343
510
  /**
344
511
  * Delete ingest bytes an addon holds (EC-671) — the ONE way they are deleted, and it deletes
345
- * only files a claim BOUND: the bytes and their binding go together. Any other id is KEPT and
346
- * returned in `kept` — an asset's, an id a write put into a row by hand, a file claimed before
347
- * bindings existed — because ingest cannot show it owns them. Call it for a refused
348
- * submission's already-claimed files and when removing a row that holds claimed files.
512
+ * only files a claim BOUND that no asset references NOW: the bytes and their binding go
513
+ * together. Any other id is KEPT and returned in `kept`:
514
+ *
515
+ * - an id with no binding — an asset's, an id a write put into a row by hand, a file claimed
516
+ * before bindings existed — because ingest cannot show it owns them;
517
+ * - EC-685: a bound file an asset row references now (`storageKey` or `thumb.storageKey` — an
518
+ * editor promoted the upload to an asset with `assets:upsert`, a legitimate edit). Ownership
519
+ * has passed to the asset: the binding is spent (deleted) and the bytes stay, so the
520
+ * asset's own delete removes them later. Read in this transaction, so no asset can acquire
521
+ * the file between the check and the delete.
522
+ *
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
525
+ * ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
526
+ * transaction.
349
527
  */
350
528
  export async function releaseIngestUploads(
351
529
  ctx: MutationCtx,
@@ -359,14 +537,27 @@ export async function releaseIngestUploads(
359
537
  kept.push(storageId)
360
538
  continue
361
539
  }
540
+ await ctx.db.delete(binding._id)
541
+ if (await assetReferencesStorage(ctx, storageId)) {
542
+ kept.push(storageId) // the asset owns the bytes now (EC-685)
543
+ continue
544
+ }
362
545
  const id = storageId as GenericId<'_storage'>
363
546
  if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
364
- await ctx.db.delete(binding._id)
365
547
  deleted.push(storageId)
366
548
  }
367
549
  return { deleted, kept }
368
550
  }
369
551
 
552
+ /**
553
+ * EC-685 — does an ingest binding hold these bytes (a claimed upload a submission still owns)?
554
+ * The one reader `assets:deleteBlob` asks before it deletes stored bytes, so an asset an
555
+ * editor created over a submission's file never takes the submission's bytes with it.
556
+ */
557
+ export async function ingestBindingHolds(ctx: QueryCtx | MutationCtx, storageId: string): Promise<boolean> {
558
+ return (await findRecordDoc(ctx, ingestBindingRecordKind, storageId)) !== null
559
+ }
560
+
370
561
  /** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
371
562
  export async function unclaimedIngestUploads(
372
563
  ctx: QueryCtx | MutationCtx,
@@ -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
+ })