@lupinum/board-core 1.0.0-beta.1 → 1.0.0-beta.2

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.
@@ -124,7 +124,29 @@ var BoardDestroyedError = class extends BoardError {
124
124
  };
125
125
 
126
126
  // src/helpers/json.ts
127
- function freezeJsonValue(value, path, ancestors = /* @__PURE__ */ new Set()) {
127
+ var DEFAULT_MAX_JSON_DEPTH = 64;
128
+ var DEFAULT_MAX_JSON_VALUES = 25e4;
129
+ var DEFAULT_MAX_JSON_STRING_CHARACTERS = 8e6;
130
+ function createJsonValueBudget() {
131
+ return {
132
+ remainingValues: DEFAULT_MAX_JSON_VALUES,
133
+ remainingStringCharacters: DEFAULT_MAX_JSON_STRING_CHARACTERS
134
+ };
135
+ }
136
+ function consumeBudget(budget, path, stringCharacters = 0) {
137
+ budget.remainingValues -= 1;
138
+ budget.remainingStringCharacters -= stringCharacters;
139
+ if (budget.remainingValues < 0 || budget.remainingStringCharacters < 0) {
140
+ throw new BoardInputError(`${path} exceeds the supported JSON size.`);
141
+ }
142
+ }
143
+ function freezeJsonValue(value, path, ancestors = /* @__PURE__ */ new Set(), budget = createJsonValueBudget(), depth = 0) {
144
+ if (depth > DEFAULT_MAX_JSON_DEPTH) {
145
+ throw new BoardInputError(
146
+ `${path} exceeds the supported JSON nesting depth.`
147
+ );
148
+ }
149
+ consumeBudget(budget, path, typeof value === "string" ? value.length : 0);
128
150
  if (value === null || typeof value === "string" || typeof value === "boolean") {
129
151
  return value;
130
152
  }
@@ -161,7 +183,13 @@ function freezeJsonValue(value, path, ancestors = /* @__PURE__ */ new Set()) {
161
183
  );
162
184
  }
163
185
  entries2.push(
164
- freezeJsonValue(descriptor.value, `${path}[${index}]`, ancestors)
186
+ freezeJsonValue(
187
+ descriptor.value,
188
+ `${path}[${index}]`,
189
+ ancestors,
190
+ budget,
191
+ depth + 1
192
+ )
165
193
  );
166
194
  }
167
195
  return Object.freeze(entries2);
@@ -181,9 +209,19 @@ function freezeJsonValue(value, path, ancestors = /* @__PURE__ */ new Set()) {
181
209
  `${path}.${key} must be an enumerable data property.`
182
210
  );
183
211
  }
212
+ budget.remainingStringCharacters -= key.length;
213
+ if (budget.remainingStringCharacters < 0) {
214
+ throw new BoardInputError(`${path} exceeds the supported JSON size.`);
215
+ }
184
216
  entries.push([
185
217
  key,
186
- freezeJsonValue(descriptor.value, `${path}.${key}`, ancestors)
218
+ freezeJsonValue(
219
+ descriptor.value,
220
+ `${path}.${key}`,
221
+ ancestors,
222
+ budget,
223
+ depth + 1
224
+ )
187
225
  ]);
188
226
  }
189
227
  return Object.freeze(Object.fromEntries(entries));
@@ -191,17 +229,20 @@ function freezeJsonValue(value, path, ancestors = /* @__PURE__ */ new Set()) {
191
229
  ancestors.delete(value);
192
230
  }
193
231
  }
194
- function freezeJsonObject(value, path) {
195
- const cloned = freezeJsonValue(value, path);
232
+ function freezeJsonObject(value, path, budget = createJsonValueBudget()) {
233
+ const cloned = freezeJsonValue(value, path, /* @__PURE__ */ new Set(), budget);
196
234
  if (Array.isArray(cloned) || cloned === null || typeof cloned !== "object") {
197
235
  throw new BoardInputError(`${path} must be a JSON object.`);
198
236
  }
199
237
  return cloned;
200
238
  }
201
- function collectJsonObjectExtras(value, knownKeys, path = "JSON object") {
239
+ function collectJsonObjectExtras(value, knownKeys, path = "JSON object", budget = createJsonValueBudget()) {
202
240
  return Object.freeze(
203
241
  Object.fromEntries(
204
- Object.entries(value).filter(([key]) => !knownKeys.has(key)).map(([key, entry]) => [key, freezeJsonValue(entry, `${path}.${key}`)])
242
+ Object.entries(value).filter(([key]) => !knownKeys.has(key)).map(([key, entry]) => [
243
+ key,
244
+ freezeJsonValue(entry, `${path}.${key}`, /* @__PURE__ */ new Set(), budget)
245
+ ])
205
246
  )
206
247
  );
207
248
  }
@@ -289,6 +330,7 @@ export {
289
330
  BoardNotFoundError,
290
331
  BoardConflictError,
291
332
  BoardDestroyedError,
333
+ createJsonValueBudget,
292
334
  freezeJsonValue,
293
335
  freezeJsonObject,
294
336
  collectJsonObjectExtras,
@@ -1,7 +1,13 @@
1
1
  import type { JsonObject, JsonValue } from '../types.js';
2
+ export interface JsonValueBudget {
3
+ remainingValues: number;
4
+ remainingStringCharacters: number;
5
+ }
6
+ /** Create one shared resource budget for an untrusted JSON input boundary. */
7
+ export declare function createJsonValueBudget(): JsonValueBudget;
2
8
  /** Clone, validate, and freeze a JSON-compatible value at an input boundary. */
3
- export declare function freezeJsonValue(value: unknown, path: string, ancestors?: Set<object>): JsonValue;
9
+ export declare function freezeJsonValue(value: unknown, path: string, ancestors?: Set<object>, budget?: JsonValueBudget, depth?: number): JsonValue;
4
10
  /** Clone, validate, and freeze a JSON object at an input boundary. */
5
- export declare function freezeJsonObject(value: unknown, path: string): JsonObject;
11
+ export declare function freezeJsonObject(value: unknown, path: string, budget?: JsonValueBudget): JsonObject;
6
12
  /** Retain unrecognized JSON fields without allowing them to override known fields. */
7
- export declare function collectJsonObjectExtras(value: Readonly<Record<string, unknown>>, knownKeys: ReadonlySet<string>, path?: string): JsonObject;
13
+ export declare function collectJsonObjectExtras(value: Readonly<Record<string, unknown>>, knownKeys: ReadonlySet<string>, path?: string, budget?: JsonValueBudget): JsonObject;
package/dist/index.js CHANGED
@@ -6,13 +6,14 @@ import {
6
6
  BoardNotFoundError,
7
7
  assertInternalBoardPlugin,
8
8
  collectJsonObjectExtras,
9
+ createJsonValueBudget,
9
10
  freezeClone,
10
11
  freezeJsonObject,
11
12
  readonlyMapView,
12
13
  readonlySetView,
13
14
  registerBoardInteractionAdapter,
14
15
  sameArray
15
- } from "./chunk-45V474TA.js";
16
+ } from "./chunk-A7SXOVDB.js";
16
17
 
17
18
  // src/math.ts
18
19
  function clamp(value, min, max) {
@@ -699,6 +700,7 @@ function validateState(state, grid, context) {
699
700
  }
700
701
  zIndexes.add(node.zIndex);
701
702
  }
703
+ validateParentCycles(state, push);
702
704
  for (const id of state.selection.values()) {
703
705
  if (!state.nodes.has(id)) {
704
706
  push("selection.exists", `Selected node ${id} does not exist.`);
@@ -755,28 +757,26 @@ function validateNodeParent(node, state, push) {
755
757
  `Node ${node.id} parent must be type "group", got "${parent.type}".`
756
758
  );
757
759
  }
758
- let walk = parent;
759
- const seen = /* @__PURE__ */ new Set();
760
- while (walk) {
761
- if (seen.has(walk.id)) {
762
- push(
763
- "node.parentId",
764
- `Cycle detected in parent chain for node ${node.id}.`
765
- );
766
- return;
767
- }
768
- seen.add(walk.id);
769
- if (walk.id === node.id) {
770
- push(
771
- "node.parentId",
772
- `Node ${node.id} would create a cycle in the parent chain.`
773
- );
774
- return;
775
- }
776
- if (!walk.parentId) {
777
- break;
760
+ }
761
+ function validateParentCycles(state, push) {
762
+ const complete = /* @__PURE__ */ new Set();
763
+ for (const start of state.nodes.keys()) {
764
+ if (complete.has(start)) continue;
765
+ const path = [];
766
+ const pathIndexes = /* @__PURE__ */ new Map();
767
+ let current = start;
768
+ while (current !== void 0 && !complete.has(current)) {
769
+ const cycleStart = pathIndexes.get(current);
770
+ if (cycleStart !== void 0) {
771
+ const cycle = [...path.slice(cycleStart), current].join(" -> ");
772
+ push("node.parentId", `Cycle detected in parent chain: ${cycle}.`);
773
+ break;
774
+ }
775
+ pathIndexes.set(current, path.length);
776
+ path.push(current);
777
+ current = state.nodes.get(current)?.parentId;
778
778
  }
779
- walk = state.nodes.get(walk.parentId);
779
+ for (const id of path) complete.add(id);
780
780
  }
781
781
  }
782
782
 
@@ -1543,6 +1543,9 @@ var JSON_CANVAS_SIDES = /* @__PURE__ */ new Set([
1543
1543
  ]);
1544
1544
  var JSON_CANVAS_EDGE_ENDS = /* @__PURE__ */ new Set(["none", "arrow"]);
1545
1545
  var JSON_CANVAS_BACKGROUND_STYLES = /* @__PURE__ */ new Set(["cover", "ratio", "repeat"]);
1546
+ var MAX_DOCUMENT_NODES = 1e4;
1547
+ var MAX_DOCUMENT_EDGES = 2e4;
1548
+ var MAX_DOCUMENT_SELECTION = 1e4;
1546
1549
  var LEGACY_BOARD_METADATA_KEY = "x-vue-board";
1547
1550
  var DOCUMENT_FIELDS = /* @__PURE__ */ new Set([
1548
1551
  "nodes",
@@ -1855,12 +1858,6 @@ function validateDocumentMetadata(metadata) {
1855
1858
  `Invalid board document: metadata for edge "${id}" has invalid data.`
1856
1859
  );
1857
1860
  }
1858
- if (edge.data !== void 0) {
1859
- freezeJsonObject(
1860
- edge.data,
1861
- `Invalid board document: metadata for edge "${id}" data`
1862
- );
1863
- }
1864
1861
  }
1865
1862
  }
1866
1863
  }
@@ -1873,6 +1870,19 @@ function normalizeDocumentForImport(raw) {
1873
1870
  if (!Array.isArray(parsed.nodes)) {
1874
1871
  throw new BoardInputError("Invalid board document: missing nodes array.");
1875
1872
  }
1873
+ if (parsed.nodes.length > MAX_DOCUMENT_NODES) {
1874
+ throw new BoardInputError(
1875
+ `Invalid board document: nodes exceed the supported limit of ${MAX_DOCUMENT_NODES}.`
1876
+ );
1877
+ }
1878
+ if (parsed.edges !== void 0 && !Array.isArray(parsed.edges)) {
1879
+ throw new BoardInputError("Invalid board document: edges must be an array.");
1880
+ }
1881
+ if (parsed.edges && parsed.edges.length > MAX_DOCUMENT_EDGES) {
1882
+ throw new BoardInputError(
1883
+ `Invalid board document: edges exceed the supported limit of ${MAX_DOCUMENT_EDGES}.`
1884
+ );
1885
+ }
1876
1886
  for (const key of [
1877
1887
  "camera",
1878
1888
  "grid",
@@ -1888,10 +1898,29 @@ function normalizeDocumentForImport(raw) {
1888
1898
  }
1889
1899
  }
1890
1900
  const rawMetadata = getDocumentMetadata(parsed);
1901
+ if (isRecord(rawMetadata)) {
1902
+ if (Array.isArray(rawMetadata.selection) && rawMetadata.selection.length > MAX_DOCUMENT_SELECTION) {
1903
+ throw new BoardInputError(
1904
+ `Invalid board document: selection exceeds the supported limit of ${MAX_DOCUMENT_SELECTION}.`
1905
+ );
1906
+ }
1907
+ if (isRecord(rawMetadata.nodes) && Reflect.ownKeys(rawMetadata.nodes).length > MAX_DOCUMENT_NODES) {
1908
+ throw new BoardInputError(
1909
+ `Invalid board document: node metadata exceeds the supported limit of ${MAX_DOCUMENT_NODES}.`
1910
+ );
1911
+ }
1912
+ if (isRecord(rawMetadata.edges) && Reflect.ownKeys(rawMetadata.edges).length > MAX_DOCUMENT_EDGES) {
1913
+ throw new BoardInputError(
1914
+ `Invalid board document: edge metadata exceeds the supported limit of ${MAX_DOCUMENT_EDGES}.`
1915
+ );
1916
+ }
1917
+ }
1891
1918
  validateDocumentMetadata(rawMetadata);
1919
+ const jsonBudget = createJsonValueBudget();
1892
1920
  const metadata = rawMetadata === void 0 ? void 0 : freezeJsonObject(
1893
1921
  rawMetadata,
1894
- "Invalid board document: board metadata"
1922
+ "Invalid board document: board metadata",
1923
+ jsonBudget
1895
1924
  );
1896
1925
  const seenNodes = /* @__PURE__ */ new Set();
1897
1926
  const nodes = parsed.nodes.map((node) => {
@@ -1917,7 +1946,8 @@ function normalizeDocumentForImport(raw) {
1917
1946
  }
1918
1947
  const normalized = freezeJsonObject(
1919
1948
  node,
1920
- `Invalid board document: node "${String(node.id ?? "?")}"`
1949
+ `Invalid board document: node "${String(node.id ?? "?")}"`,
1950
+ jsonBudget
1921
1951
  );
1922
1952
  validateJsonCanvasNodeFields(normalized);
1923
1953
  if (seenNodes.has(normalized.id)) {
@@ -1930,11 +1960,6 @@ function normalizeDocumentForImport(raw) {
1930
1960
  });
1931
1961
  let edges;
1932
1962
  if (parsed.edges !== void 0) {
1933
- if (!Array.isArray(parsed.edges)) {
1934
- throw new BoardInputError(
1935
- "Invalid board document: edges must be an array."
1936
- );
1937
- }
1938
1963
  const seenEdges = /* @__PURE__ */ new Set();
1939
1964
  edges = parsed.edges.map((edge) => {
1940
1965
  if (!isRecord(edge)) {
@@ -1988,7 +2013,8 @@ function normalizeDocumentForImport(raw) {
1988
2013
  }
1989
2014
  return freezeJsonObject(
1990
2015
  { ...edge, id, fromNode, toNode },
1991
- `Invalid board document: edge "${id}"`
2016
+ `Invalid board document: edge "${id}"`,
2017
+ jsonBudget
1992
2018
  );
1993
2019
  });
1994
2020
  }
@@ -1996,7 +2022,8 @@ function normalizeDocumentForImport(raw) {
1996
2022
  ...collectJsonObjectExtras(
1997
2023
  parsedRecord,
1998
2024
  DOCUMENT_FIELDS,
1999
- "Invalid board document"
2025
+ "Invalid board document",
2026
+ jsonBudget
2000
2027
  ),
2001
2028
  nodes,
2002
2029
  ...edges !== void 0 ? { edges } : {},
@@ -2033,12 +2060,13 @@ function documentToSnapshot(document) {
2033
2060
  jsonNodeToBoardNode(node, metadata?.nodes?.[node.id], index)
2034
2061
  )
2035
2062
  );
2063
+ const nodeIds = new Set(nodes.map((node) => node.id));
2036
2064
  const gridSettings = {
2037
2065
  ...DEFAULT_GRID,
2038
2066
  ...metadata?.grid ?? {}
2039
2067
  };
2040
2068
  const selection = Array.isArray(metadata?.selection) ? metadata.selection.filter(
2041
- (id) => typeof id === "string" && nodes.some((node) => node.id === id)
2069
+ (id) => typeof id === "string" && nodeIds.has(id)
2042
2070
  ).map((id) => id) : [];
2043
2071
  const nextZIndex = metadata?.nextZIndex ?? nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1;
2044
2072
  const camera = { ...DEFAULT_CAMERA, ...metadata?.camera ?? {} };
@@ -3002,11 +3030,23 @@ function createBoardEngine(options = {}) {
3002
3030
  notifySnapGuidesChanged();
3003
3031
  return idMap;
3004
3032
  }
3033
+ const reservedIds = new Set(state.nodes.keys());
3034
+ for (const rawNode of snapshotNodes) {
3035
+ let id = rawNode.id;
3036
+ while (reservedIds.has(id)) id = createNodeId();
3037
+ reservedIds.add(id);
3038
+ idMap.set(rawNode.id, id);
3039
+ }
3005
3040
  for (const rawNode of snapshotNodes) {
3006
3041
  const node = normalizeExistingNode(rawNode);
3007
- const id = state.nodes.has(node.id) ? createNodeId() : node.id;
3008
- state.nodes.set(id, { ...node, id, zIndex: state.nextZIndex++ });
3009
- idMap.set(node.id, id);
3042
+ const id = idMap.get(node.id);
3043
+ const parentId = node.parentId ? idMap.get(node.parentId) ?? node.parentId : void 0;
3044
+ state.nodes.set(id, {
3045
+ ...node,
3046
+ id,
3047
+ parentId,
3048
+ zIndex: state.nextZIndex++
3049
+ });
3010
3050
  }
3011
3051
  state.jsonCanvas = mergeJsonCanvasExtras(
3012
3052
  state.jsonCanvas,
@@ -3677,13 +3717,27 @@ function createBoardEngine(options = {}) {
3677
3717
  return runCommand("pasteData", [payload, offset], () => {
3678
3718
  const inputs = options.clipboard?.deserialize(payload) ?? null;
3679
3719
  if (!inputs || inputs.length === 0) return null;
3680
- const created = inputs.map(
3681
- (input) => normalizeNode({
3682
- ...input,
3683
- x: input.x ?? offset.x,
3684
- y: input.y ?? offset.y
3685
- })
3686
- );
3720
+ const stagedNodes = new Map(state.nodes);
3721
+ let nextZIndex = state.nextZIndex;
3722
+ const created = inputs.map((input) => {
3723
+ const normalized = normalizeNodeInput(
3724
+ {
3725
+ ...input,
3726
+ x: input.x ?? offset.x,
3727
+ y: input.y ?? offset.y
3728
+ },
3729
+ {
3730
+ nodes: stagedNodes,
3731
+ grid,
3732
+ constraints: nodeConstraints,
3733
+ nextZIndex
3734
+ }
3735
+ );
3736
+ nextZIndex = normalized.nextZIndex;
3737
+ stagedNodes.set(normalized.node.id, normalized.node);
3738
+ return normalized.node;
3739
+ });
3740
+ state.nextZIndex = nextZIndex;
3687
3741
  for (const node of created) {
3688
3742
  state.nodes.set(node.id, node);
3689
3743
  emit("node:created", materializeNode2(node));
package/dist/internal.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  isBoardInteractiveEventTarget,
16
16
  isEventOwnedByBoardRoot,
17
17
  readonlyMapView
18
- } from "./chunk-45V474TA.js";
18
+ } from "./chunk-A7SXOVDB.js";
19
19
  export {
20
20
  BOARD_EDITOR_ATTRIBUTE,
21
21
  BOARD_INTERACTIVE_ATTRIBUTE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/board-core",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Headless node-based board engine for spatial editors, diagramming tools, and whiteboard-style interfaces.",
5
5
  "license": "MIT",
6
6
  "author": "Lupinum OG <info@lupinum.com> (https://lupinum.com)",