@field/cascade 0.12.0 → 0.13.0

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.
Files changed (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +50 -0
  3. package/README.md +1 -1
  4. package/dist/cli/{chunk-OM6BEME6.js → chunk-2L34ERS2.js} +3 -3
  5. package/dist/cli/{chunk-3RXL5GHB.js → chunk-JEW5MPGI.js} +4 -4
  6. package/dist/cli/{chunk-ST6LROUG.js → chunk-OBZAP3GG.js} +4 -4
  7. package/dist/cli/{chunk-73S4JZXK.js → chunk-QAGATJGE.js} +5 -5
  8. package/dist/cli/{chunk-MP653A5O.js → chunk-QKL7CCRS.js} +84 -10
  9. package/dist/cli/{chunk-2JEU3MGB.js → chunk-W5PJ5RMP.js} +155 -83
  10. package/dist/cli/{chunk-EU4ZFZ7Y.js → chunk-W7OUAT7J.js} +2 -2
  11. package/dist/cli/{cliCommands-JJN5Z3ZC.js → cliCommands-XVFRD7CF.js} +3 -3
  12. package/dist/cli/{commands-PHYFYA3F.js → commands-M2XLTPTZ.js} +7 -7
  13. package/dist/cli/{doctorCommand-ORWQRNYP.js → doctorCommand-RAEI7GVY.js} +2 -2
  14. package/dist/cli/{editorCommands-NFR32JOY.js → editorCommands-5BBG4S6V.js} +6 -6
  15. package/dist/cli/{graphCommands-EOUOBOIJ.js → graphCommands-MULQ7VD2.js} +2 -2
  16. package/dist/cli/{graphFile-YTGJB5F5.js → graphFile-CITF36ZM.js} +3 -3
  17. package/dist/cli/{helpCommand-PULH6ZYB.js → helpCommand-X2QDANM3.js} +2 -2
  18. package/dist/cli/index.js +13 -13
  19. package/dist/cli/{mcp-JVZFQOEX.js → mcp-YRIGRLYQ.js} +3 -3
  20. package/dist/cli/{parity-X6DEH2DV.js → parity-3N3HTKWX.js} +4 -4
  21. package/dist/cli/{project-VYGIA7JX.js → project-SJF5ZF3F.js} +2 -2
  22. package/dist/cli/{projectRuntime-2Q4JKEH3.js → projectRuntime-NY4XMDFH.js} +3 -3
  23. package/dist/cli/{render-PT4MWI6U.js → render-Y23W6MY6.js} +10 -5
  24. package/dist/cli/{runCommand-HNPQVZMS.js → runCommand-XOG7AVC5.js} +3 -3
  25. package/dist/cli/{runner-5XPXVW7J.js → runner-DNJO5WO2.js} +5 -5
  26. package/dist/cli/{share-MHF6TNRK.js → share-B7HTCOJX.js} +5 -5
  27. package/dist/cli/{webBuild-WPPMGGW7.js → webBuild-2K4COCGY.js} +4 -4
  28. package/dist/contracts/capabilities.d.ts +21 -0
  29. package/dist/player/app.js +8 -5
  30. package/dist/player/embed.js +2 -0
  31. package/dist/player/types.d.ts +6 -0
  32. package/dist/runtime/definition/architecture.js +89 -0
  33. package/doc/PROJECT_AUTHORING.md +60 -1
  34. package/doc/WEB_PLAYER.md +9 -1
  35. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -138,7 +138,7 @@ Definition rules:
138
138
  - Vector props support `min`, `max`, and `step`, shared across components. Props may declare a default `expression`; an explicitly saved value overrides that default, including when it equals the numeric default.
139
139
  - WebGPU nodes declare `gpu` and use its shared device and per-node `cache`. Portable GPU nodes can run through optional Dawn in the CLI; browser-only declarations remain refused. Import constants and explicit `readTexture` (RGBA8 only) from `cascade/gpu`. Graph texture exchange remains future work. See `doc/HEADLESS_GPU.md` for bounded agent renders, diagnostics and hardware tests.
140
140
  - Treat graph wires as the primary data boundary. A node may observe only its connected inputs, its props, and declared host capabilities; it must not inspect sibling nodes or the graph to discover data.
141
- - Deterministic node modules may not retain module-level mutable values, access ambient state such as `globalThis`, browser storage, or `process`, run top-level effects, or import sibling node implementations. `cascade check` reports these as `architecture/*` diagnostics.
141
+ - Deterministic node modules may not retain module-level mutable values, access ambient state such as `globalThis`, browser storage, or `process`, run top-level effects, or import sibling node implementations. `cascade check` reports these as `architecture/*` diagnostics. Pure module-level helpers are allowed: function declarations, and `const` values that cannot change (literals and arithmetic, `Math.PI`, arrow functions, arrays and objects under `as const` or `Object.freeze`). `let`, `new Map()`, a bare `[]`/`{}`, a regex or any other call is module state.
142
142
  - A cache may memoize computation from wired inputs, but it must never supply unwired data. Make cache use an explicitly labelled prop and keep cache keys derived from the complete wired input and relevant props.
143
143
  - Keep model/provider integrations in the project. Cascade core has no Google,
144
144
  Anthropic, OpenAI, or generic `ai` capability. Use normal typed outputs such
package/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ Notable changes to Cascade. Newest first.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.13.0 — 2026-11-09
8
+
9
+ ### Added
10
+
11
+ - **Browser player without chrome.** `mount(…, { chrome: false })`, or
12
+ `player.html?chrome=0` in a plain iframe, shows the output alone on a
13
+ transparent page: no dark frame, Play button or frame field. See
14
+ `doc/WEB_PLAYER.md`.
15
+ - **Read-only roots outside the project.** `"reads": ["../other/data"]` in
16
+ `cascade.json` lets file reads (`files`, `cascade/io`, Studio media) resolve
17
+ into another folder by `..` or a symlink; writes there are still refused,
18
+ and any other path out of the project still is. See "Reading another
19
+ project's data" in `doc/PROJECT_AUTHORING.md`.
20
+ - **Named exports and a keyed cache for nodes.** `"writes": ["renders"]` in
21
+ `cascade.json` lets the `files` capability write stably named files into a
22
+ declared project folder beside `.cascade-cache/`. `files.cache.get(key)` /
23
+ `files.cache.put(key, data)` (contract `KeyedCache`) keep entries under
24
+ `.cascade-cache/keyed/<key>`, found again by key on the next run, where
25
+ `cascade/io` names carry a content hash. `files.write` is now atomic, and
26
+ normalises before its folder test, so `.cascade-cache/../x` is refused.
27
+ - **Node files may hold immutable module-level constants.** Helper functions
28
+ were already allowed; `architecture/module-state` now also passes `const`s
29
+ that cannot change (`const TAU = Math.PI * 2`, `[...] as const`,
30
+ `Object.freeze({...})`, arrow-function helpers). `let`, `new Map()`, a bare
31
+ array or object, a regex and other calls are still refused.
32
+
33
+ ### Fixed
34
+
35
+ - **The browser player re-cooks only what changed.** `setProp`, `setInput`
36
+ and each frame now cook the changed node and what lies downstream of it;
37
+ an unchanged upstream node keeps its outputs (`reuse: "strict"`), so a
38
+ paint prop no longer re-runs a slow data node above it.
39
+ - **`cascade render --workers` accepts a piece animated on twos.** Two frames
40
+ whose node saved the same drawing land on one content-named cache file
41
+ (`.cascade-cache/<node>.<hash>.png`); that is the same bytes, not a
42
+ clobber, so the fixed-path probe no longer refuses it.
43
+
44
+ ### Changed
45
+
46
+ - **`FileCapability` has a required `cache: KeyedCache` member.** A custom
47
+ host that implements the `files` capability itself must now provide
48
+ `cache.get(key)` and `cache.put(key, data)`; it no longer type-checks
49
+ without them. Studio and the headless host already do.
50
+
51
+ ### Known gaps
52
+
53
+ - **Ramp's stop editor and Image's file picker are not in Studio yet.** The
54
+ definition-v1 port of the image nodes left them out. Graphs that already
55
+ set them render unchanged.
56
+
7
57
  ## 0.12.0 — 2026-11-09
8
58
 
9
59
  ### Added
package/README.md CHANGED
@@ -36,7 +36,7 @@ cascade . # reopen it later
36
36
  cascade run index.cascade --frames 1 --json # render headlessly
37
37
  ```
38
38
 
39
- The npm package is at 0.12.0 and includes every command below. 0.12 adds `cascade share`, `ladder --cairn`, scrub number fields and the full GPU limits; see the [changelog](CHANGELOG.md).
39
+ The npm package is at 0.13.0 and includes every command below. 0.13 adds the chrome-free player, read-only roots and named writes in `cascade.json`, a keyed node cache and module-level constants in node files; see the [changelog](CHANGELOG.md).
40
40
 
41
41
  ## New in 0.9: shareable editors
42
42
 
@@ -3,14 +3,14 @@ import {
3
3
  moduleId,
4
4
  projectModuleFile,
5
5
  validateNodeModuleArchitecture
6
- } from "./chunk-MP653A5O.js";
6
+ } from "./chunk-QKL7CCRS.js";
7
7
  import {
8
8
  builtinNodeRegistration
9
9
  } from "./chunk-MI5JLQYA.js";
10
10
  import {
11
11
  ProjectRoot,
12
12
  extractNodeDefinition
13
- } from "./chunk-2JEU3MGB.js";
13
+ } from "./chunk-W5PJ5RMP.js";
14
14
 
15
15
  // src/cli/webBuild.ts
16
16
  import fs from "node:fs/promises";
@@ -264,4 +264,4 @@ export {
264
264
  inside,
265
265
  exists
266
266
  };
267
- //# sourceMappingURL=chunk-OM6BEME6.js.map
267
+ //# sourceMappingURL=chunk-2L34ERS2.js.map
@@ -5,21 +5,21 @@ import {
5
5
  inside,
6
6
  playerFile,
7
7
  staticRuntimePlugin
8
- } from "./chunk-OM6BEME6.js";
8
+ } from "./chunk-2L34ERS2.js";
9
9
  import {
10
10
  PREFLIGHT_WARNING_CODES,
11
11
  createRuntime,
12
12
  moduleId,
13
13
  projectModuleFile,
14
14
  validateNodeModuleArchitecture
15
- } from "./chunk-MP653A5O.js";
15
+ } from "./chunk-QKL7CCRS.js";
16
16
  import {
17
17
  builtinNodeRegistration
18
18
  } from "./chunk-MI5JLQYA.js";
19
19
  import {
20
20
  ProjectRoot,
21
21
  extractNodeDefinition
22
- } from "./chunk-2JEU3MGB.js";
22
+ } from "./chunk-W5PJ5RMP.js";
23
23
 
24
24
  // packages/runtime/src/editor/index.ts
25
25
  var SCENE_FORMAT = "cascade.scene";
@@ -813,4 +813,4 @@ export {
813
813
  scanForLeaks,
814
814
  deployVercel
815
815
  };
816
- //# sourceMappingURL=chunk-3RXL5GHB.js.map
816
+ //# sourceMappingURL=chunk-JEW5MPGI.js.map
@@ -27,7 +27,7 @@ import {
27
27
  runDeterministicProjectGraph,
28
28
  saveStillImages,
29
29
  validateProjectGraph
30
- } from "./chunk-MP653A5O.js";
30
+ } from "./chunk-QKL7CCRS.js";
31
31
  import {
32
32
  graphSeed
33
33
  } from "./chunk-L6B6FUQP.js";
@@ -37,14 +37,14 @@ import {
37
37
  import {
38
38
  compileEmbedded,
39
39
  compileProjectModule
40
- } from "./chunk-EU4ZFZ7Y.js";
40
+ } from "./chunk-W7OUAT7J.js";
41
41
  import {
42
42
  cascadeVersion,
43
43
  formatStageTrace
44
44
  } from "./chunk-5ORUQ6ES.js";
45
45
  import {
46
46
  ProjectRoot
47
- } from "./chunk-2JEU3MGB.js";
47
+ } from "./chunk-W5PJ5RMP.js";
48
48
 
49
49
  // src/cli/runner.ts
50
50
  import * as fs from "fs/promises";
@@ -264,4 +264,4 @@ export {
264
264
  renderSuggestion,
265
265
  runGraph
266
266
  };
267
- //# sourceMappingURL=chunk-ST6LROUG.js.map
267
+ //# sourceMappingURL=chunk-OBZAP3GG.js.map
@@ -7,14 +7,14 @@ import {
7
7
  formatPlan,
8
8
  fromBase64,
9
9
  parseScene
10
- } from "./chunk-3RXL5GHB.js";
10
+ } from "./chunk-JEW5MPGI.js";
11
11
  import {
12
12
  cookProjectGraph,
13
13
  installProjectIo
14
- } from "./chunk-MP653A5O.js";
14
+ } from "./chunk-QKL7CCRS.js";
15
15
  import {
16
16
  ProjectRoot
17
- } from "./chunk-2JEU3MGB.js";
17
+ } from "./chunk-W5PJ5RMP.js";
18
18
 
19
19
  // src/cli/editorCommands.ts
20
20
  import fs from "node:fs/promises";
@@ -34,7 +34,7 @@ Shareable editors (see doc/EDITOR_EXPORT.md):
34
34
  async function runEditorCommand(args) {
35
35
  const [command, graph] = args;
36
36
  if (command === "share") {
37
- const { runShareCommand } = await import("./share-MHF6TNRK.js");
37
+ const { runShareCommand } = await import("./share-B7HTCOJX.js");
38
38
  return runShareCommand(args);
39
39
  }
40
40
  if (!graph || graph.startsWith("-")) throw new Error(`cascade ${command} requires a graph file`);
@@ -333,4 +333,4 @@ export {
333
333
  runEditorCommand,
334
334
  sceneDocument
335
335
  };
336
- //# sourceMappingURL=chunk-73S4JZXK.js.map
336
+ //# sourceMappingURL=chunk-QAGATJGE.js.map
@@ -62,7 +62,7 @@ import {
62
62
  import {
63
63
  ProjectRoot,
64
64
  extractNodeDefinition
65
- } from "./chunk-2JEU3MGB.js";
65
+ } from "./chunk-W5PJ5RMP.js";
66
66
  import {
67
67
  CAPABILITIES_BY_ENVIRONMENT,
68
68
  validateNodeDefinition
@@ -2237,6 +2237,9 @@ function validateNodeModuleArchitecture(source, fileName, ts2) {
2237
2237
  (statement) => ts2.isVariableStatement(statement) && isDefinition(statement)
2238
2238
  );
2239
2239
  for (const statement of file.statements) {
2240
+ if (ts2.isVariableStatement(statement) && !isDefinition(statement) && !supersededByDefinition(statement) && isPureConstant(statement)) {
2241
+ continue;
2242
+ }
2240
2243
  if (ts2.isVariableStatement(statement) && !isDefinition(statement)) {
2241
2244
  const superseded = supersededByDefinition(statement);
2242
2245
  add(
@@ -2301,6 +2304,48 @@ function validateNodeModuleArchitecture(source, fileName, ts2) {
2301
2304
  if (!declaration || !ts2.isIdentifier(declaration.name)) return null;
2302
2305
  return declaration.name.text === "icon" || declaration.name.text === "runsOn" ? declaration.name.text : null;
2303
2306
  }
2307
+ function isPureConstant(statement) {
2308
+ if (!(statement.declarationList.flags & ts2.NodeFlags.Const)) return false;
2309
+ return statement.declarationList.declarations.every(
2310
+ (declaration) => ts2.isIdentifier(declaration.name) && declaration.initializer !== void 0 && isPure(declaration.initializer, false)
2311
+ );
2312
+ }
2313
+ function isPure(node, frozen) {
2314
+ if (ts2.isNumericLiteral(node) || ts2.isStringLiteral(node) || ts2.isBigIntLiteral(node) || ts2.isNoSubstitutionTemplateLiteral(node) || node.kind === ts2.SyntaxKind.TrueKeyword || node.kind === ts2.SyntaxKind.FalseKeyword || node.kind === ts2.SyntaxKind.NullKeyword) return true;
2315
+ if (ts2.isIdentifier(node)) return true;
2316
+ if (ts2.isArrowFunction(node) || ts2.isFunctionExpression(node)) return true;
2317
+ if (ts2.isParenthesizedExpression(node)) return isPure(node.expression, frozen);
2318
+ if (ts2.isAsExpression(node))
2319
+ return isPure(node.expression, frozen || isConstAssertion(node.type));
2320
+ if (ts2.isSatisfiesExpression(node) || ts2.isNonNullExpression(node) || ts2.isTypeAssertionExpression(node))
2321
+ return isPure(node.expression, frozen);
2322
+ if (ts2.isPrefixUnaryExpression(node))
2323
+ return node.operator !== ts2.SyntaxKind.PlusPlusToken && node.operator !== ts2.SyntaxKind.MinusMinusToken && isPure(node.operand, false);
2324
+ if (ts2.isBinaryExpression(node))
2325
+ return !isAssignment(node.operatorToken.kind) && isPure(node.left, false) && isPure(node.right, false);
2326
+ if (ts2.isConditionalExpression(node))
2327
+ return isPure(node.condition, false) && isPure(node.whenTrue, frozen) && isPure(node.whenFalse, frozen);
2328
+ if (ts2.isTemplateExpression(node))
2329
+ return node.templateSpans.every((span) => isPure(span.expression, false));
2330
+ if (ts2.isPropertyAccessExpression(node)) return isPure(node.expression, false);
2331
+ if (ts2.isElementAccessExpression(node))
2332
+ return isPure(node.expression, false) && isPure(node.argumentExpression, false);
2333
+ if (ts2.isArrayLiteralExpression(node))
2334
+ return frozen && node.elements.every((element) => !ts2.isSpreadElement(element) && isPure(element, true));
2335
+ if (ts2.isObjectLiteralExpression(node))
2336
+ return frozen && node.properties.every((property) => ts2.isPropertyAssignment(property) ? !ts2.isComputedPropertyName(property.name) && isPure(property.initializer, true) : ts2.isShorthandPropertyAssignment(property));
2337
+ if (ts2.isCallExpression(node) && ts2.isPropertyAccessExpression(node.expression) && ts2.isIdentifier(node.expression.expression) && node.expression.expression.text === "Object" && node.expression.name.text === "freeze" && node.arguments.length === 1) {
2338
+ const [argument] = node.arguments;
2339
+ return ts2.isArrayLiteralExpression(argument) ? argument.elements.every((element) => !ts2.isSpreadElement(element) && isPure(element, false)) : ts2.isObjectLiteralExpression(argument) ? argument.properties.every((property) => ts2.isPropertyAssignment(property) && !ts2.isComputedPropertyName(property.name) && isPure(property.initializer, false)) : false;
2340
+ }
2341
+ return false;
2342
+ }
2343
+ function isConstAssertion(type) {
2344
+ return ts2.isTypeReferenceNode(type) && ts2.isIdentifier(type.typeName) && type.typeName.text === "const";
2345
+ }
2346
+ function isAssignment(kind) {
2347
+ return kind >= ts2.SyntaxKind.FirstAssignment && kind <= ts2.SyntaxKind.LastAssignment;
2348
+ }
2304
2349
  function isDefinition(statement) {
2305
2350
  return Boolean(statement.declarationList.flags & ts2.NodeFlags.Const) && Boolean(statement.modifiers?.some((item) => item.kind === ts2.SyntaxKind.ExportKeyword)) && statement.declarationList.declarations.length === 1 && ts2.isIdentifier(statement.declarationList.declarations[0].name) && statement.declarationList.declarations[0].name.text === "definition";
2306
2351
  }
@@ -2337,25 +2382,41 @@ function normalize(value) {
2337
2382
  }
2338
2383
 
2339
2384
  // src/cli/nodeFiles.ts
2385
+ import { randomBytes } from "node:crypto";
2340
2386
  import fs from "node:fs/promises";
2341
2387
  import path from "node:path";
2388
+ var KEYED_DIR = ".cascade-cache/keyed";
2389
+ var KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
2342
2390
  function createNodeFileCapability(project) {
2343
2391
  const within2 = (relative, what) => {
2344
2392
  try {
2345
- return project.resolve(relative.replace(/^\.?\//, ""));
2393
+ const clean = relative.replace(/^\.?\//, "");
2394
+ return what === "writing" ? project.resolve(clean) : project.resolveMedia(clean);
2346
2395
  } catch (error) {
2347
2396
  throw new Error(
2348
2397
  `files: ${what} outside the project is refused \u2014 ${relative} (${error instanceof Error ? error.message : String(error)})`
2349
2398
  );
2350
2399
  }
2351
2400
  };
2352
- const cacheOnly = (relative) => {
2401
+ const writable = (relative) => {
2353
2402
  const clean = relative.replace(/^\.?\//, "");
2354
- if (!clean.startsWith(".cascade-cache/"))
2403
+ const full = within2(clean, "writing");
2404
+ if (!project.writeFolderOf(clean))
2355
2405
  throw new Error(
2356
- `files: writes are confined to .cascade-cache/ \u2014 got ${clean}. A node's output is derived data, and the same rule applies in the browser.`
2406
+ `files: writes are confined to .cascade-cache/ and the folders cascade.json "writes" declares \u2014 got ${clean}. A node's output is derived data; declare an export folder such as "writes": ["renders"] to write named files.`
2357
2407
  );
2358
- return within2(clean, "writing");
2408
+ return full;
2409
+ };
2410
+ const writeWhole2 = async (full, data) => {
2411
+ await fs.mkdir(path.dirname(full), { recursive: true });
2412
+ const staging = `${full}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
2413
+ await fs.writeFile(staging, data);
2414
+ await fs.rename(staging, full);
2415
+ };
2416
+ const keyed = (key) => {
2417
+ if (typeof key !== "string" || key.length > 512 || !KEY.test(key))
2418
+ throw new Error(`files.cache: key ${JSON.stringify(key)} must be letters, digits, ".", "_" and "-" in "/"-separated parts, each starting with a letter or digit`);
2419
+ return within2(`${KEYED_DIR}/${key}`, "writing");
2359
2420
  };
2360
2421
  const live = (options) => {
2361
2422
  if (options.signal.aborted) throw new Error("files: cancelled");
@@ -2367,9 +2428,22 @@ function createNodeFileCapability(project) {
2367
2428
  },
2368
2429
  async write(relative, data, options) {
2369
2430
  live(options);
2370
- const full = cacheOnly(relative);
2371
- await fs.mkdir(path.dirname(full), { recursive: true });
2372
- await fs.writeFile(full, data);
2431
+ await writeWhole2(writable(relative), data);
2432
+ },
2433
+ cache: {
2434
+ async get(key, options) {
2435
+ live(options);
2436
+ try {
2437
+ return new Uint8Array(await fs.readFile(keyed(key)));
2438
+ } catch (error) {
2439
+ if (error.code === "ENOENT") return null;
2440
+ throw error;
2441
+ }
2442
+ },
2443
+ async put(key, data, options) {
2444
+ live(options);
2445
+ await writeWhole2(keyed(key), data);
2446
+ }
2373
2447
  },
2374
2448
  async list(relative, options) {
2375
2449
  live(options);
@@ -3763,4 +3837,4 @@ export {
3763
3837
  builtinModuleIds,
3764
3838
  diagnoseGraph
3765
3839
  };
3766
- //# sourceMappingURL=chunk-MP653A5O.js.map
3840
+ //# sourceMappingURL=chunk-QKL7CCRS.js.map
@@ -39,6 +39,19 @@ function resolveWithinRoot(root, ...segments) {
39
39
  }
40
40
  return full;
41
41
  }
42
+ function canonicalPath(full) {
43
+ let existing = full;
44
+ while (!existsIncludingSymlink(existing)) {
45
+ const parent = path.dirname(existing);
46
+ if (parent === existing) return full;
47
+ existing = parent;
48
+ }
49
+ return path.join(canonical(existing, `path is not accessible: ${full}`), path.relative(existing, full));
50
+ }
51
+ function isWithin(base, candidate) {
52
+ const rel = path.relative(base, candidate);
53
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
54
+ }
42
55
  function existsIncludingSymlink(candidate) {
43
56
  try {
44
57
  fs.lstatSync(candidate);
@@ -56,6 +69,101 @@ function canonical(candidate, message) {
56
69
  }
57
70
  }
58
71
 
72
+ // server/src/projectConfig.ts
73
+ import fs2 from "node:fs";
74
+ import path2 from "node:path";
75
+ var CREDENTIAL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
76
+ var SECRET_KEY = /^(?:api[_-]?key|apiKey|secret|password|access[_-]?token|refresh[_-]?token|credential|credentials)$/i;
77
+ function readProjectManifest(root) {
78
+ const raw = readRaw(root);
79
+ return normalizeManifest(raw);
80
+ }
81
+ function writeProjectManifest(root, input) {
82
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("project manifest must be an object");
83
+ const value = input;
84
+ const current = readRaw(root);
85
+ const next = {
86
+ ...current,
87
+ name: typeof value.name === "string" ? value.name.trim() : "",
88
+ commands: stringRecord(value.commands, "commands"),
89
+ credentials: credentialNames(value.credentials),
90
+ settings: settingsRecord(value.settings)
91
+ };
92
+ fs2.writeFileSync(path2.join(root, "cascade.json"), `${JSON.stringify(next, null, 2)}
93
+ `, "utf8");
94
+ return normalizeManifest(next);
95
+ }
96
+ function readRaw(root) {
97
+ try {
98
+ const value = JSON.parse(fs2.readFileSync(path2.join(root, "cascade.json"), "utf8"));
99
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("root must be an object");
100
+ return value;
101
+ } catch (error) {
102
+ if (error.code === "ENOENT") return {};
103
+ throw new Error(`Cannot read cascade.json: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ }
106
+ function normalizeManifest(raw) {
107
+ return Object.freeze({
108
+ name: typeof raw.name === "string" ? raw.name : "",
109
+ commands: Object.freeze(stringRecord(raw.commands, "commands")),
110
+ credentials: Object.freeze(credentialNames(raw.credentials)),
111
+ settings: Object.freeze(settingsRecord(raw.settings)),
112
+ reads: Object.freeze(relativeFolders(raw.reads, "reads", true)),
113
+ writes: Object.freeze(relativeFolders(raw.writes, "writes", false))
114
+ });
115
+ }
116
+ function relativeFolders(input, label, mayLeave) {
117
+ if (input === void 0) return [];
118
+ const shape = mayLeave ? '"../other/data"' : '"renders"';
119
+ if (!Array.isArray(input)) throw new Error(`cascade.json ${label} must be an array of relative folders such as [${shape}]`);
120
+ const folders = input.map((entry) => {
121
+ if (typeof entry !== "string" || !entry.trim() || entry.includes("\0") || path2.isAbsolute(entry) || /^~/.test(entry))
122
+ throw new Error(`cascade.json ${label}: ${JSON.stringify(entry)} is not a relative folder such as ${shape}`);
123
+ const folder = path2.posix.normalize(entry.split("\\").join("/")).replace(/\/+$/, "");
124
+ const leaves = folder === ".." || folder.startsWith("../");
125
+ if (!mayLeave && (leaves || folder === "." || /^(?:\.git|nodes|\.cascade)(?:\/|$)/.test(folder)))
126
+ throw new Error(`cascade.json writes: ${JSON.stringify(entry)} must be a folder inside the project, not its root, .git, .cascade or nodes`);
127
+ if (mayLeave && folder === ".") throw new Error('cascade.json reads: "." is the project itself, which is always readable');
128
+ return folder;
129
+ });
130
+ return [...new Set(folders)];
131
+ }
132
+ function stringRecord(input, label) {
133
+ if (input === void 0) return {};
134
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`${label} must be an object of strings`);
135
+ const entries = Object.entries(input);
136
+ if (entries.some(([key, value]) => !CREDENTIAL_NAME.test(key) || typeof value !== "string" || value.includes("\0"))) {
137
+ throw new Error(`${label} must contain safe names and string values`);
138
+ }
139
+ return Object.fromEntries(entries);
140
+ }
141
+ function credentialNames(input) {
142
+ if (input === void 0) return [];
143
+ if (!Array.isArray(input) || input.some((name) => typeof name !== "string" || !CREDENTIAL_NAME.test(name))) {
144
+ throw new Error("credentials must be an array of safe names");
145
+ }
146
+ return [...new Set(input)];
147
+ }
148
+ function settingsRecord(input) {
149
+ if (input === void 0) return {};
150
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("settings must be an object");
151
+ assertSafeSettings(input, 0);
152
+ return structuredClone(input);
153
+ }
154
+ function assertSafeSettings(value, depth) {
155
+ if (depth > 12) throw new Error("settings nesting is too deep");
156
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return;
157
+ if (Array.isArray(value)) return value.forEach((item) => assertSafeSettings(item, depth + 1));
158
+ if (!value || typeof value !== "object") throw new Error("settings must contain JSON values");
159
+ for (const [key, item] of Object.entries(value)) {
160
+ if (key === "__proto__" || key === "constructor" || key === "prototype" || SECRET_KEY.test(key)) {
161
+ throw new Error(`settings key ${JSON.stringify(key)} looks secret or unsafe; credentials belong outside the project`);
162
+ }
163
+ assertSafeSettings(item, depth + 1);
164
+ }
165
+ }
166
+
59
167
  // server/src/shell/service.ts
60
168
  import fs4 from "node:fs";
61
169
  import os2 from "node:os";
@@ -64,11 +172,11 @@ import { execFile, spawn } from "node:child_process";
64
172
  import { randomUUID } from "node:crypto";
65
173
 
66
174
  // server/src/credentials.ts
67
- import fs2 from "node:fs";
175
+ import fs3 from "node:fs";
68
176
  import os from "node:os";
69
- import path2 from "node:path";
177
+ import path3 from "node:path";
70
178
  var CredentialStore = class {
71
- constructor(file = process.env.CASCADE_CREDENTIALS || path2.join(os.homedir(), ".cascade", "credentials.yaml")) {
179
+ constructor(file = process.env.CASCADE_CREDENTIALS || path3.join(os.homedir(), ".cascade", "credentials.yaml")) {
72
180
  this.file = file;
73
181
  }
74
182
  has(name) {
@@ -96,7 +204,7 @@ var CredentialStore = class {
96
204
  read() {
97
205
  let source;
98
206
  try {
99
- source = fs2.readFileSync(this.file, "utf8");
207
+ source = fs3.readFileSync(this.file, "utf8");
100
208
  } catch (error) {
101
209
  if (error.code === "ENOENT") return /* @__PURE__ */ new Map();
102
210
  throw error;
@@ -170,83 +278,6 @@ function safeEnvName(value) {
170
278
  return value && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value) && !/^(?:PATH|NODE_OPTIONS|PYTHONPATH|LD_.*|DYLD_.*)$/i.test(value) ? value : null;
171
279
  }
172
280
 
173
- // server/src/projectConfig.ts
174
- import fs3 from "node:fs";
175
- import path3 from "node:path";
176
- var CREDENTIAL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
177
- var SECRET_KEY = /^(?:api[_-]?key|apiKey|secret|password|access[_-]?token|refresh[_-]?token|credential|credentials)$/i;
178
- function readProjectManifest(root) {
179
- const raw = readRaw(root);
180
- return normalizeManifest(raw);
181
- }
182
- function writeProjectManifest(root, input) {
183
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("project manifest must be an object");
184
- const value = input;
185
- const current = readRaw(root);
186
- const next = {
187
- ...current,
188
- name: typeof value.name === "string" ? value.name.trim() : "",
189
- commands: stringRecord(value.commands, "commands"),
190
- credentials: credentialNames(value.credentials),
191
- settings: settingsRecord(value.settings)
192
- };
193
- fs3.writeFileSync(path3.join(root, "cascade.json"), `${JSON.stringify(next, null, 2)}
194
- `, "utf8");
195
- return normalizeManifest(next);
196
- }
197
- function readRaw(root) {
198
- try {
199
- const value = JSON.parse(fs3.readFileSync(path3.join(root, "cascade.json"), "utf8"));
200
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("root must be an object");
201
- return value;
202
- } catch (error) {
203
- if (error.code === "ENOENT") return {};
204
- throw new Error(`Cannot read cascade.json: ${error instanceof Error ? error.message : String(error)}`);
205
- }
206
- }
207
- function normalizeManifest(raw) {
208
- return Object.freeze({
209
- name: typeof raw.name === "string" ? raw.name : "",
210
- commands: Object.freeze(stringRecord(raw.commands, "commands")),
211
- credentials: Object.freeze(credentialNames(raw.credentials)),
212
- settings: Object.freeze(settingsRecord(raw.settings))
213
- });
214
- }
215
- function stringRecord(input, label) {
216
- if (input === void 0) return {};
217
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`${label} must be an object of strings`);
218
- const entries = Object.entries(input);
219
- if (entries.some(([key, value]) => !CREDENTIAL_NAME.test(key) || typeof value !== "string" || value.includes("\0"))) {
220
- throw new Error(`${label} must contain safe names and string values`);
221
- }
222
- return Object.fromEntries(entries);
223
- }
224
- function credentialNames(input) {
225
- if (input === void 0) return [];
226
- if (!Array.isArray(input) || input.some((name) => typeof name !== "string" || !CREDENTIAL_NAME.test(name))) {
227
- throw new Error("credentials must be an array of safe names");
228
- }
229
- return [...new Set(input)];
230
- }
231
- function settingsRecord(input) {
232
- if (input === void 0) return {};
233
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("settings must be an object");
234
- assertSafeSettings(input, 0);
235
- return structuredClone(input);
236
- }
237
- function assertSafeSettings(value, depth) {
238
- if (depth > 12) throw new Error("settings nesting is too deep");
239
- if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return;
240
- if (Array.isArray(value)) return value.forEach((item) => assertSafeSettings(item, depth + 1));
241
- if (!value || typeof value !== "object") throw new Error("settings must contain JSON values");
242
- for (const [key, item] of Object.entries(value)) {
243
- if (key === "__proto__" || key === "constructor" || key === "prototype" || SECRET_KEY.test(key)) {
244
- throw new Error(`settings key ${JSON.stringify(key)} looks secret or unsafe; credentials belong outside the project`);
245
- }
246
- assertSafeSettings(item, depth + 1);
247
- }
248
- }
249
-
250
281
  // server/src/shell/types.ts
251
282
  var ShellProcessError = class extends Error {
252
283
  constructor(kind, result, message) {
@@ -847,7 +878,48 @@ var ProjectRoot = class _ProjectRoot {
847
878
  if (normalized === "shared" || normalized.startsWith("shared/")) {
848
879
  return this.resolveShared(...normalized.split("/").slice(1));
849
880
  }
850
- return this.resolve(relativePath);
881
+ try {
882
+ return this.resolve(relativePath);
883
+ } catch (error) {
884
+ if (!(error instanceof PathSafetyError)) throw error;
885
+ const declared = this.resolveDeclaredRead(relativePath);
886
+ if (declared) return declared;
887
+ throw error;
888
+ }
889
+ }
890
+ /**
891
+ * A read outside the project, allowed only into a root the project declares
892
+ * in cascade.json `"reads"` (`["../other/data"]`). Reached by `..` or by a
893
+ * symlink alike: the canonical target must lie under the canonical read
894
+ * root. Never used for writes — `resolve()` still refuses those.
895
+ */
896
+ resolveDeclaredRead(relativePath) {
897
+ if (path5.isAbsolute(relativePath)) return null;
898
+ const reads = readProjectManifest(this.root).reads;
899
+ if (!reads.length) return null;
900
+ const target = canonicalPath(path5.resolve(this.root, relativePath));
901
+ for (const folder of reads) {
902
+ let base;
903
+ try {
904
+ base = fssync.realpathSync(path5.resolve(this.root, folder));
905
+ } catch {
906
+ continue;
907
+ }
908
+ if (isWithin(base, target)) return target;
909
+ }
910
+ return null;
911
+ }
912
+ /**
913
+ * Where a node may write a file under its own name: `.cascade-cache/`, or a
914
+ * folder the project declares in cascade.json `"writes"` (`["renders"]`).
915
+ * Returns the folder the normalised path falls in, or null. Containment is
916
+ * still `resolve()`'s, so a symlink out of a write folder is refused.
917
+ */
918
+ writeFolderOf(relativePath) {
919
+ const clean = path5.posix.normalize(relativePath.split("\\").join("/").replace(/^\.?\//, ""));
920
+ for (const folder of [".cascade-cache", ...readProjectManifest(this.root).writes])
921
+ if (clean.startsWith(`${folder}/`)) return folder;
922
+ return null;
851
923
  }
852
924
  /**
853
925
  * Whether this project is under version control, walking **up** rather than
@@ -1122,4 +1194,4 @@ export {
1122
1194
  extractNodeDefinition,
1123
1195
  ProjectRoot
1124
1196
  };
1125
- //# sourceMappingURL=chunk-2JEU3MGB.js.map
1197
+ //# sourceMappingURL=chunk-W5PJ5RMP.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  readProjectManifest
3
- } from "./chunk-2JEU3MGB.js";
3
+ } from "./chunk-W5PJ5RMP.js";
4
4
 
5
5
  // server/src/compile.ts
6
6
  import * as esbuild from "esbuild";
@@ -119,4 +119,4 @@ export {
119
119
  compileProjectPanel,
120
120
  compileEmbedded
121
121
  };
122
- //# sourceMappingURL=chunk-EU4ZFZ7Y.js.map
122
+ //# sourceMappingURL=chunk-W7OUAT7J.js.map
@@ -5,7 +5,7 @@ import {
5
5
  compileEmbedded,
6
6
  compileProjectModule,
7
7
  compileProjectPanel
8
- } from "./chunk-EU4ZFZ7Y.js";
8
+ } from "./chunk-W7OUAT7J.js";
9
9
  import {
10
10
  PythonWorker,
11
11
  StageCancelledError,
@@ -34,7 +34,7 @@ import {
34
34
  resolveWithinRoot,
35
35
  secretValue,
36
36
  writeProjectManifest
37
- } from "./chunk-2JEU3MGB.js";
37
+ } from "./chunk-W5PJ5RMP.js";
38
38
  import "./chunk-NJFMDXHR.js";
39
39
  import "./chunk-J7VK7MNF.js";
40
40
  import "./chunk-FWMSGKEH.js";
@@ -3467,4 +3467,4 @@ export {
3467
3467
  resolveProjectTarget,
3468
3468
  runStudioCli
3469
3469
  };
3470
- //# sourceMappingURL=cliCommands-JJN5Z3ZC.js.map
3470
+ //# sourceMappingURL=cliCommands-XVFRD7CF.js.map