@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 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`.
@@ -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.safeParse(raw)
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.71",
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.71"
153
+ "@elytracms/core": "^0.0.72"
154
154
  },
155
155
  "peerDependencies": {
156
156
  "@tanstack/react-router": "^1.170.0",