@elytracms/studio 0.0.71 → 0.0.72
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/cms.ts +8 -0
- package/convex/delivery.ts +12 -2
- package/convex/write-limits.ts +62 -0
- package/package.json +2 -2
package/convex/cms.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { foldDocument, unfoldDocument } from '@elytracms/core/cms-core'
|
|
|
3
3
|
import type { CmsDocument } from '@elytracms/core/cms-core'
|
|
4
4
|
import { mutation, query } from './functions'
|
|
5
5
|
import { requireCapability } from './guard'
|
|
6
|
+
import { assertWithinWriteLimits } from './write-limits'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* CMS document persistence (EC-043). The backend stores **documents only**
|
|
@@ -44,6 +45,11 @@ export const upsertDocument = mutation({
|
|
|
44
45
|
},
|
|
45
46
|
handler: async (ctx, args) => {
|
|
46
47
|
await requireCapability(ctx, 'content.write') // EC-150
|
|
48
|
+
// EC-304 B-4: defensive server-side depth/byte clamp at the `v.any()` chokepoint,
|
|
49
|
+
// BEFORE fold/write. Rejects abusive structurally-deep / oversized payloads that a
|
|
50
|
+
// direct Convex call (bypassing the client-side lens) could otherwise land. Limits
|
|
51
|
+
// are generous — legitimate content never trips them (see write-limits.ts).
|
|
52
|
+
assertWithinWriteLimits(args.document, 'document')
|
|
47
53
|
// EC-260: fold for storage. `args.document` arrives UNFOLDED over the 64-level
|
|
48
54
|
// arg wire; the trigger unfolds before extracting references, so folding here
|
|
49
55
|
// (strictly before the `ctx.db` write) never hides a reference from the walker.
|
|
@@ -134,6 +140,8 @@ export const saveDocumentVersion = mutation({
|
|
|
134
140
|
},
|
|
135
141
|
handler: async (ctx, args) => {
|
|
136
142
|
await requireCapability(ctx, 'content.write') // EC-150
|
|
143
|
+
// EC-304 B-4: same defensive depth/byte clamp on the version snapshot chokepoint.
|
|
144
|
+
assertWithinWriteLimits(args.snapshot, 'snapshot')
|
|
137
145
|
// EC-302: versions are appended monotonically, so the latest-created row holds
|
|
138
146
|
// the highest version. Read just that one (index-ordered, newest first) instead
|
|
139
147
|
// of collecting the whole — unbounded — history to compute `max+1`.
|
package/convex/delivery.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
2
|
import { unfoldValue } from '@elytracms/core/cms-core'
|
|
3
3
|
import { dataSourceSchema } from '@elytracms/core/data-binding'
|
|
4
|
+
import { safeParseTotal } from '@elytracms/core/project-graph'
|
|
4
5
|
import { query } from './functions'
|
|
5
6
|
import type { QueryCtx } from './functions'
|
|
6
7
|
import { requireCapability } from './guard'
|
|
@@ -95,8 +96,17 @@ const PROVIDER_FUNCTION_PREFIX = 'provider:'
|
|
|
95
96
|
* - `rest`-kind sources never leave the deployment (their headers could hold
|
|
96
97
|
* credentials), and the captured sample/schema envelope (builder preview
|
|
97
98
|
* state, not delivery config) is stripped for every kind.
|
|
99
|
+
*
|
|
100
|
+
* EC-304 B-1: this runs on the PUBLIC `delivery:snapshot` read path, so it must
|
|
101
|
+
* NEVER throw — a hostile `cms-data-sources` row whose `values.config` decodes to a
|
|
102
|
+
* multi-thousand-deep JSON payload would overflow the recursive `dataSourceSchema`
|
|
103
|
+
* and make a plain `.safeParse` THROW a `RangeError`, escaping `loadSnapshot` and
|
|
104
|
+
* 500-ing the public query for every page of the live site. Both parses here degrade
|
|
105
|
+
* instead: `JSON.parse` is already `try`/`catch`-guarded, and `safeParseTotal`
|
|
106
|
+
* turns the schema-side stack overflow into the ordinary `!success` skip below.
|
|
107
|
+
* (Exported for the EC-304 regression pin.)
|
|
98
108
|
*/
|
|
99
|
-
function dataSourcesFromSystemCollection(documents: unknown[]): unknown[] {
|
|
109
|
+
export function dataSourcesFromSystemCollection(documents: unknown[]): unknown[] {
|
|
100
110
|
const sources: unknown[] = []
|
|
101
111
|
for (const document of documents) {
|
|
102
112
|
if (typeof document !== 'object' || document === null) continue
|
|
@@ -112,7 +122,7 @@ function dataSourcesFromSystemCollection(documents: unknown[]): unknown[] {
|
|
|
112
122
|
} catch {
|
|
113
123
|
continue
|
|
114
124
|
}
|
|
115
|
-
const parsed = dataSourceSchema
|
|
125
|
+
const parsed = safeParseTotal(dataSourceSchema, raw)
|
|
116
126
|
if (!parsed.success) continue
|
|
117
127
|
const source = parsed.data
|
|
118
128
|
if (source.kind === 'rest') continue
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { MAX_UNFOLDED_DOCUMENT_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EC-304 B-4 — a defensive server-side shape/size clamp at the `v.any()` write
|
|
5
|
+
* chokepoint (`cms:upsertDocument` / `saveDocumentVersion`).
|
|
6
|
+
*
|
|
7
|
+
* The canonical Zod write-time lens runs CLIENT-side (the operations layer); a direct
|
|
8
|
+
* Convex call bypasses it and lands `document`/`snapshot` as `v.any()`. This clamp
|
|
9
|
+
* re-checks depth + byte size at the Convex trust boundary before `foldDocument`/write.
|
|
10
|
+
* It is a defensive bound at the EXISTING boundary — NOT a new write path or authority
|
|
11
|
+
* (the write still requires `content.write`; this only rejects abusive payloads).
|
|
12
|
+
*
|
|
13
|
+
* IMPORTANT — this does NOT close EC-304 B-1. B-1's payload hides a deep JSON string
|
|
14
|
+
* INSIDE a single field-value STRING leaf, so the document is structurally shallow at
|
|
15
|
+
* this chokepoint and a document-structure depth clamp never sees it. B-1's guard is
|
|
16
|
+
* the read-side `safeParseTotal` wrap in `delivery.ts` + the rich-text parses. This
|
|
17
|
+
* clamp only bounds structurally-deep and oversized documents (belt-and-suspenders).
|
|
18
|
+
*
|
|
19
|
+
* The limits are deliberately generous — they must NEVER reject legitimate content:
|
|
20
|
+
*
|
|
21
|
+
* - DEPTH: `MAX_UNFOLDED_DOCUMENT_DEPTH` (62), the ceiling an unfolded document can
|
|
22
|
+
* even cross the Convex mutation-arg wire at (the EC-260 codec folds deep field
|
|
23
|
+
* values only for STORAGE; the arg wire is the hard limit). Real content's deepest
|
|
24
|
+
* measured nesting is ≈ 29, and the client lens already errors at 63, so this fires
|
|
25
|
+
* only on pathological nesting — and converts the otherwise-opaque Convex wire
|
|
26
|
+
* rejection into a clear, actionable error. `measureMaxDepth` is safe to run here:
|
|
27
|
+
* Convex bounds arg depth at 64, so the received value is never deep enough to
|
|
28
|
+
* overflow the measure itself.
|
|
29
|
+
* - BYTES: 1 MiB, Convex's own per-document value cap. A legitimate document body is
|
|
30
|
+
* ≈ 20 KB, so this has ~50× head-room; it only pre-empts the storage-abuse case
|
|
31
|
+
* with a clear error instead of an opaque backend failure. `JSON.stringify` is safe
|
|
32
|
+
* because the depth check runs first (≤ 62 deep cannot overflow the serializer).
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_WRITE_DOCUMENT_DEPTH = MAX_UNFOLDED_DOCUMENT_DEPTH
|
|
35
|
+
|
|
36
|
+
/** Convex's documented per-document value size cap (1 MiB). */
|
|
37
|
+
export const MAX_WRITE_DOCUMENT_BYTES = 1_048_576
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Throw when `value` exceeds the depth or byte clamp. `label` names the offending
|
|
41
|
+
* field for the error message (e.g. `"document"` / `"snapshot"`). Runs BEFORE
|
|
42
|
+
* `foldDocument` and the `ctx.db` write, so an abusive payload never reaches storage.
|
|
43
|
+
*/
|
|
44
|
+
export function assertWithinWriteLimits(value: unknown, label: string): void {
|
|
45
|
+
const { depth } = measureMaxDepth(value)
|
|
46
|
+
if (depth > MAX_WRITE_DOCUMENT_DEPTH) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${label} nests ${depth} levels deep, past the ${MAX_WRITE_DOCUMENT_DEPTH}-level write limit — split it into smaller linked documents.`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
const bytes = byteLength(JSON.stringify(value))
|
|
52
|
+
if (bytes > MAX_WRITE_DOCUMENT_BYTES) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`${label} is ${bytes} bytes, past the ${MAX_WRITE_DOCUMENT_BYTES}-byte (1 MiB) write limit.`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** UTF-8 byte length of a string, using the web-standard `TextEncoder` (available in Convex). */
|
|
60
|
+
function byteLength(text: string): number {
|
|
61
|
+
return new TextEncoder().encode(text).length
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/studio",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.72",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "The Elytra studio engine (UI + lib + routes + data-only Convex schema) as an importable package. Extraction staged in EXTRACTION.md (EC-223); Stages 1-2 = UI primitives + router-free lib leaves.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"lucide-react": "^1.23.0",
|
|
151
151
|
"tailwind-merge": "^3.6.0",
|
|
152
152
|
"zod": "^4.4.3",
|
|
153
|
-
"@elytracms/core": "^0.0.
|
|
153
|
+
"@elytracms/core": "^0.0.72"
|
|
154
154
|
},
|
|
155
155
|
"peerDependencies": {
|
|
156
156
|
"@tanstack/react-router": "^1.170.0",
|