@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.
- package/convex/ingest.test.ts +84 -7
- package/convex/ingest.ts +110 -31
- package/convex/path-segment-refusal.test.ts +201 -0
- package/convex/records-column-transform.convex.test.ts +147 -0
- package/convex/schema.ts +1 -1
- package/dist/addon.d.ts +5 -3
- package/dist/addon.js +4 -3
- package/dist/addon.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +10 -3
- package/dist/components/app-views/default-modules.js +5 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +15 -8
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +29 -1
- package/dist/components/builder/reuse-context.js +58 -12
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +3 -2
- package/dist/components/cms/cms-store.js +8 -2
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.js +15 -5
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +4 -1
- package/dist/components/cms/use-document-draft.js +4 -1
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +7 -0
- package/dist/components/cms/use-publish-controller.js +17 -3
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/versions-pane.js +8 -5
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +34 -16
- package/dist/components/delivery-health-banner.js +59 -27
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/issue-severity.d.ts +3 -2
- package/dist/components/issue-severity.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +39 -24
- package/dist/components/nav-issues-registry.js +50 -21
- package/dist/components/nav-issues-registry.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -50
- package/dist/components/nav-issues.js +31 -59
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/notifications/notice-center.d.ts +61 -0
- package/dist/components/notifications/notice-center.js +119 -0
- package/dist/components/notifications/notice-center.js.map +1 -0
- package/dist/components/notifications/notice-model.d.ts +137 -0
- package/dist/components/notifications/notice-model.js +107 -0
- package/dist/components/notifications/notice-model.js.map +1 -0
- package/dist/components/notifications/outcome-notice.d.ts +16 -0
- package/dist/components/notifications/outcome-notice.js +34 -0
- package/dist/components/notifications/outcome-notice.js.map +1 -0
- package/dist/components/notifications/shell-findings.d.ts +36 -0
- package/dist/components/notifications/shell-findings.js +140 -0
- package/dist/components/notifications/shell-findings.js.map +1 -0
- package/dist/components/rail-view-source.js +5 -0
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-rail.d.ts +10 -2
- package/dist/components/records/record-rail.js +74 -4
- package/dist/components/records/record-rail.js.map +1 -1
- package/dist/components/section-nav.d.ts +15 -3
- package/dist/components/section-nav.js +39 -147
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +8 -3
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/use-narrow-viewport.d.ts +11 -0
- package/dist/components/use-narrow-viewport.js +25 -0
- package/dist/components/use-narrow-viewport.js.map +1 -0
- package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
- package/dist/components/users/role-policy-drift-issues.js +17 -8
- package/dist/components/users/role-policy-drift-issues.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +4 -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 +21 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +19 -2
- package/dist/lib/i18n/messages.en.js +24 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/notices/toast-store.d.ts +61 -0
- package/dist/lib/notices/toast-store.js +116 -0
- package/dist/lib/notices/toast-store.js.map +1 -0
- package/dist/lib/operations/cms-persistence.d.ts +24 -4
- package/dist/lib/operations/cms-persistence.js +417 -80
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +61 -30
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/ui/toast.d.ts +24 -16
- package/dist/ui/toast.js +23 -32
- package/dist/ui/toast.js.map +1 -1
- package/package.json +3 -3
package/convex/ingest.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
45
|
-
* stored
|
|
46
|
-
* SHA-256 (EC-685) — and that it matches the declared size and type, and binds
|
|
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
|
|
184
|
-
// stored file against
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
297
|
-
export
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
432
|
+
async function judgeIngestUploadClaim(
|
|
366
433
|
ctx: MutationCtx,
|
|
367
434
|
claim: IngestUploadClaim,
|
|
368
435
|
target: string,
|
|
369
436
|
receivedAt: string,
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
425
|
-
// spelling checks (2) and (3) read, never the string the visitor sent.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
445
|
-
*
|
|
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
|
+
})
|