@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.
- package/convex/assets.ts +16 -2
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +269 -16
- package/convex/ingest.ts +238 -47
- package/convex/path-segment-refusal.test.ts +201 -0
- package/convex/prototype-key.test.ts +92 -0
- package/convex/records-column-transform.convex.test.ts +147 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -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 +5 -6
- package/dist/components/cms/cms-store.js +12 -24
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +31 -6
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +39 -3
- package/dist/components/cms/use-document-draft.js +46 -16
- 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-lens.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 +24 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +22 -2
- package/dist/lib/i18n/messages.en.js +27 -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 +32 -7
- package/dist/lib/operations/cms-persistence.js +511 -71
- 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/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.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 +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
|
|
43
|
-
* stored
|
|
44
|
-
* and
|
|
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
|
-
/**
|
|
52
|
-
|
|
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
|
|
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
|
|
149
|
-
// 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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
247
|
-
export type
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
270
|
-
): Promise<
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
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
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
432
|
+
async function judgeIngestUploadClaim(
|
|
303
433
|
ctx: MutationCtx,
|
|
304
434
|
claim: IngestUploadClaim,
|
|
305
435
|
target: string,
|
|
306
436
|
receivedAt: string,
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
//
|
|
337
|
-
// spelling checks (2) and (3) read, never the string the visitor sent.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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
|
+
})
|