@openfairygui/backend 0.3.0 → 0.3.1

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/dist/node.cjs CHANGED
@@ -23,8 +23,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  let node_fs_promises = require("node:fs/promises");
25
25
  node_fs_promises = __toESM(node_fs_promises);
26
+ let node_os = require("node:os");
27
+ node_os = __toESM(node_os);
26
28
  let node_path = require("node:path");
27
29
  node_path = __toESM(node_path);
30
+ let node_crypto = require("node:crypto");
28
31
  let _openfairygui_core_uam = require("@openfairygui/core/uam");
29
32
  let _openfairygui_functions_uam = require("@openfairygui/functions/uam");
30
33
  let _openfairygui_core_project_io = require("@openfairygui/core/project-io");
@@ -59,6 +62,16 @@ function createRuntimePathPolicy() {
59
62
  workspaceBoundary: "project-root-only"
60
63
  };
61
64
  }
65
+ async function assertProjectPathContained(fileSystem, projectRoot, targetPath) {
66
+ const [resolvedRoot, resolvedTarget] = await Promise.all([fileSystem.resolvePath(projectRoot), fileSystem.resolvePath(targetPath)]);
67
+ const root = normalizeComparablePath(resolvedRoot);
68
+ const target = normalizeComparablePath(resolvedTarget);
69
+ if (root === "." && !target.startsWith("/") && !/^[a-z]:\//i.test(target)) return;
70
+ if (target === root || target.startsWith(`${root}/`)) return;
71
+ const error = /* @__PURE__ */ new Error(`Project path escapes the opened root: ${targetPath}`);
72
+ error.code = "EACCES";
73
+ throw error;
74
+ }
62
75
  async function resolveFairyPath(fileSystem, input) {
63
76
  const resolvedInput = fileSystem.resolve(input);
64
77
  const stat = await fileSystem.stat(resolvedInput);
@@ -147,10 +160,14 @@ function failure(stage, startedAt, error, session, options) {
147
160
  }
148
161
  //#endregion
149
162
  //#region src/services/session-project-writer.ts
150
- function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
163
+ function createWriterFileSystem(fileSystem, projectRoot, writtenPaths, failedPaths) {
164
+ const contained = async (targetPath, operation) => {
165
+ await assertProjectPathContained(fileSystem, projectRoot, targetPath);
166
+ return operation();
167
+ };
151
168
  async function trackWrite(targetPath, write) {
152
169
  try {
153
- const result = await write();
170
+ const result = await contained(targetPath, write);
154
171
  writtenPaths.push(targetPath);
155
172
  return result;
156
173
  } catch (error) {
@@ -159,8 +176,8 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
159
176
  }
160
177
  }
161
178
  return {
162
- readFile: (path) => fileSystem.readFile(path),
163
- readFileRaw: (path) => fileSystem.readFileRaw(path),
179
+ readFile: (path) => contained(path, () => fileSystem.readFile(path)),
180
+ readFileRaw: (path) => contained(path, () => fileSystem.readFileRaw(path)),
164
181
  writeFile: (path, content) => trackWrite(path, async () => {
165
182
  await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
166
183
  await fileSystem.writeFile(path, content);
@@ -169,9 +186,10 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
169
186
  await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
170
187
  await fileSystem.writeFileRaw(path, data);
171
188
  }),
172
- mkdir: (path) => fileSystem.mkdir(path, { recursive: true }),
173
- readdir: (path) => fileSystem.readdir(path),
189
+ mkdir: (path) => contained(path, () => fileSystem.mkdir(path, { recursive: true })),
190
+ readdir: (path) => contained(path, () => fileSystem.readdir(path)),
174
191
  async exists(path) {
192
+ await assertProjectPathContained(fileSystem, projectRoot, path);
175
193
  try {
176
194
  await fileSystem.stat(path);
177
195
  return true;
@@ -186,11 +204,21 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
186
204
  };
187
205
  }
188
206
  async function writeSessionProject(input) {
189
- await new _openfairygui_core_project_io.ProjectWriter(createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
190
- staleSourceFiles: input.staleSourceFiles,
191
- staleResourceFolders: input.staleResourceFolders,
192
- staleBranchDirectories: input.staleBranchDirectories
193
- });
207
+ const projectRoot = input.fileSystem.dirname(input.fairyPath);
208
+ const write = async (fileSystem) => {
209
+ await new _openfairygui_core_project_io.ProjectWriter(createWriterFileSystem(fileSystem, projectRoot, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
210
+ staleSourceFiles: input.staleSourceFiles,
211
+ staleResourceFolders: input.staleResourceFolders,
212
+ staleBranchDirectories: input.staleBranchDirectories
213
+ });
214
+ };
215
+ if (!input.fileSystem.runProjectWriteTransaction) return write(input.fileSystem);
216
+ try {
217
+ await input.fileSystem.runProjectWriteTransaction(projectRoot, write);
218
+ } catch (error) {
219
+ input.writtenPaths.length = 0;
220
+ throw error;
221
+ }
194
222
  }
195
223
  //#endregion
196
224
  //#region src/services/session-utils.ts
@@ -504,7 +532,7 @@ var AuthoringService = class {
504
532
  lastSavedRevision: session.lastSavedRevision,
505
533
  committedPaths,
506
534
  failedPaths,
507
- diskMayBePartiallyUpdated: true
535
+ diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction
508
536
  }, toSessionSnapshot(session, this.context.capabilities), {
509
537
  sessionId: session.sessionId,
510
538
  revision: session.revision
@@ -679,7 +707,7 @@ var AuthoringService = class {
679
707
  failedPaths,
680
708
  skippedPaths,
681
709
  diagnostics: diagnosticsFromError,
682
- diskMayBePartiallyUpdated: true
710
+ diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction
683
711
  }, toSessionSnapshot(session, this.context.capabilities), {
684
712
  sessionId: session.sessionId,
685
713
  revision: session.revision,
@@ -1168,7 +1196,7 @@ var ReadService = class {
1168
1196
  //#endregion
1169
1197
  //#region src/services/runtime-service.ts
1170
1198
  function randomId() {
1171
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1199
+ return crypto.randomUUID();
1172
1200
  }
1173
1201
  function createCapabilityUnavailableError(capability) {
1174
1202
  const artifactCapability = capability === "artifact.publish" || capability === "artifact.restore";
@@ -1181,29 +1209,33 @@ function createCapabilityUnavailableError(capability) {
1181
1209
  bridgeBoundary: artifactCapability ? "external-bridge" : void 0
1182
1210
  };
1183
1211
  }
1184
- function createProjectReaderFileSystem(fileSystem) {
1212
+ function createProjectReaderFileSystem(fileSystem, projectRoot) {
1213
+ const contained = async (filePath, operation) => {
1214
+ await assertProjectPathContained(fileSystem, projectRoot, filePath);
1215
+ return operation();
1216
+ };
1185
1217
  return {
1186
1218
  readFile(filePath) {
1187
- return fileSystem.readFile(filePath);
1219
+ return contained(filePath, () => fileSystem.readFile(filePath));
1188
1220
  },
1189
1221
  readFileRaw(filePath) {
1190
- return fileSystem.readFileRaw(filePath);
1222
+ return contained(filePath, () => fileSystem.readFileRaw(filePath));
1191
1223
  },
1192
1224
  writeFile(filePath, content) {
1193
- return fileSystem.writeFile(filePath, content);
1225
+ return contained(filePath, () => fileSystem.writeFile(filePath, content));
1194
1226
  },
1195
1227
  writeFileRaw(filePath, data) {
1196
- return fileSystem.writeFileRaw(filePath, data);
1228
+ return contained(filePath, () => fileSystem.writeFileRaw(filePath, data));
1197
1229
  },
1198
1230
  async mkdir(dirPath) {
1199
- await fileSystem.mkdir(dirPath, { recursive: true });
1231
+ await contained(dirPath, () => fileSystem.mkdir(dirPath, { recursive: true }));
1200
1232
  },
1201
1233
  readdir(dirPath) {
1202
- return fileSystem.readdir(dirPath);
1234
+ return contained(dirPath, () => fileSystem.readdir(dirPath));
1203
1235
  },
1204
1236
  async exists(filePath) {
1205
1237
  try {
1206
- await fileSystem.stat(filePath);
1238
+ await contained(filePath, () => fileSystem.stat(filePath));
1207
1239
  return true;
1208
1240
  } catch {
1209
1241
  return false;
@@ -1297,9 +1329,25 @@ var RuntimeService = class {
1297
1329
  const startedAt = Date.now();
1298
1330
  if (!this.context.fileSystem) return failure("runtime", startedAt, createCapabilityUnavailableError("fileSystem"));
1299
1331
  const fileSystem = this.context.fileSystem;
1332
+ if (this.context.allowedProjectRoots?.length) {
1333
+ let allowed = false;
1334
+ for (const root of this.context.allowedProjectRoots) try {
1335
+ await assertProjectPathContained(fileSystem, root, input.projectPath);
1336
+ allowed = true;
1337
+ break;
1338
+ } catch (error) {
1339
+ if (error.code !== "EACCES") throw error;
1340
+ }
1341
+ if (!allowed) return failure("runtime", startedAt, {
1342
+ code: "project_root_not_allowed",
1343
+ message: "Project path is outside the configured allowed roots.",
1344
+ projectPath: input.projectPath
1345
+ });
1346
+ }
1300
1347
  const { fairyPath, canonicalProjectPath, canonicalPathKey } = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
1348
+ await fileSystem.validateProjectRoot?.(canonicalProjectPath);
1301
1349
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
1302
- const lockFilePath = fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
1350
+ const lockFilePath = fileSystem.getSessionLockPath?.(canonicalProjectPath) ?? fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
1303
1351
  if (existingSessionId) return failure("runtime", startedAt, {
1304
1352
  code: "lock_conflict",
1305
1353
  kind: "in_process_session_exists",
@@ -1319,7 +1367,7 @@ var RuntimeService = class {
1319
1367
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1320
1368
  canonicalPathKey
1321
1369
  }));
1322
- const read = await new _openfairygui_core_project_io.ProjectReader(createProjectReaderFileSystem(fileSystem)).readDetailed(fairyPath, { hydrateResourceBytes: true });
1370
+ const read = await new _openfairygui_core_project_io.ProjectReader(createProjectReaderFileSystem(fileSystem, canonicalProjectPath)).readDetailed(fairyPath, { hydrateResourceBytes: true });
1323
1371
  if (!read.document) throw new Error(read.diagnostics[0]?.message ?? `Unable to read project: ${fairyPath}`);
1324
1372
  const document = read.document;
1325
1373
  const project = (0, _openfairygui_core_uam.liftDocumentToUamProject)(document);
@@ -1377,6 +1425,11 @@ var RuntimeService = class {
1377
1425
  openProjectSession(input) {
1378
1426
  const startedAt = Date.now();
1379
1427
  const sessionId = input.sessionId ?? randomId();
1428
+ if (this.context.sessions.has(sessionId)) return failure("runtime", startedAt, {
1429
+ code: "session_id_conflict",
1430
+ message: `Session id is already in use: ${sessionId}`,
1431
+ sessionId
1432
+ });
1380
1433
  const storage = input.storage;
1381
1434
  const memoryProjectPath = `memory://${sessionId}`;
1382
1435
  const canonicalProjectPath = storage?.canonicalProjectPath ?? input.canonicalProjectPath ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || "." : memoryProjectPath);
@@ -1503,7 +1556,7 @@ const ARTIFACT_BRIDGE_CAPABILITY = {
1503
1556
  bridgeEntrypoint: "@openfairygui/backend/node",
1504
1557
  reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1505
1558
  };
1506
- function createCapabilities() {
1559
+ function createCapabilities(atomicSave = false) {
1507
1560
  return {
1508
1561
  contractVersion: BACKEND_CONTRACT_VERSION,
1509
1562
  capabilitySchemaVersion: 3,
@@ -1575,7 +1628,7 @@ function createCapabilities() {
1575
1628
  sessionRuntime: true,
1576
1629
  advisoryLocking: true,
1577
1630
  coordinatedSave: true,
1578
- atomicSave: false,
1631
+ atomicSave,
1579
1632
  staleRevisionProtection: true,
1580
1633
  pathPolicy: createRuntimePathPolicy(),
1581
1634
  events: {
@@ -1621,10 +1674,11 @@ var BackendRuntime = class {
1621
1674
  jobService;
1622
1675
  constructor(options = {}) {
1623
1676
  this.fileSystem = options.fileSystem;
1624
- this.capabilities = createCapabilities();
1677
+ this.capabilities = createCapabilities(Boolean(options.fileSystem?.runProjectWriteTransaction));
1625
1678
  this.context = {
1626
1679
  fileSystem: this.fileSystem,
1627
1680
  host: options.host,
1681
+ allowedProjectRoots: options.allowedProjectRoots,
1628
1682
  capabilities: this.capabilities,
1629
1683
  sessions: this.sessions,
1630
1684
  sessionsByPath: this.sessionsByPath,
@@ -1647,7 +1701,16 @@ var BackendRuntime = class {
1647
1701
  return this.readService.getCapabilities();
1648
1702
  }
1649
1703
  async openSession(input) {
1650
- return this.runtimeService.openSession(input);
1704
+ const startedAt = Date.now();
1705
+ try {
1706
+ return await this.runtimeService.openSession(input);
1707
+ } catch {
1708
+ return failure("runtime", startedAt, {
1709
+ code: "project_open_failed",
1710
+ message: "Unable to open project.",
1711
+ projectPath: input.projectPath
1712
+ });
1713
+ }
1651
1714
  }
1652
1715
  openProjectSession(input) {
1653
1716
  return this.runtimeService.openProjectSession(input);
@@ -1671,7 +1734,7 @@ var BackendRuntime = class {
1671
1734
  return this.authoringService.materializeSession(input);
1672
1735
  }
1673
1736
  async closeSession(input) {
1674
- return this.runtimeService.closeSession(input);
1737
+ return this.authoringService.runSessionExclusive(input.sessionId, () => this.runtimeService.closeSession(input));
1675
1738
  }
1676
1739
  getEvents(input) {
1677
1740
  return this.eventService.getEvents(input);
@@ -1694,13 +1757,161 @@ var BackendRuntime = class {
1694
1757
  };
1695
1758
  //#endregion
1696
1759
  //#region src/node.ts
1760
+ const PROCESS_START_TIME = Math.trunc(Date.now() - process.uptime() * 1e3);
1761
+ function parseLockMetadata(content) {
1762
+ try {
1763
+ const value = JSON.parse(content);
1764
+ if (value.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || !Number.isFinite(value.processStartTime) || typeof value.hostname !== "string" || typeof value.token !== "string") return null;
1765
+ return value;
1766
+ } catch {
1767
+ return null;
1768
+ }
1769
+ }
1770
+ function isProcessAlive(metadata) {
1771
+ if (metadata.hostname !== node_os.default.hostname()) return true;
1772
+ if (metadata.pid === process.pid) return Math.abs(metadata.processStartTime - PROCESS_START_TIME) < 1e3;
1773
+ try {
1774
+ process.kill(metadata.pid, 0);
1775
+ return true;
1776
+ } catch (error) {
1777
+ return error.code !== "ESRCH";
1778
+ }
1779
+ }
1780
+ async function recoverStaleLock(filePath) {
1781
+ let before;
1782
+ try {
1783
+ before = await node_fs_promises.default.readFile(filePath, "utf-8");
1784
+ } catch {
1785
+ return false;
1786
+ }
1787
+ const metadata = parseLockMetadata(before);
1788
+ if (!metadata || isProcessAlive(metadata)) return false;
1789
+ const current = parseLockMetadata(await node_fs_promises.default.readFile(filePath, "utf-8").catch(() => ""));
1790
+ if (!current || current.token !== metadata.token) return false;
1791
+ await node_fs_promises.default.unlink(filePath);
1792
+ return true;
1793
+ }
1794
+ async function resolvePathThroughExistingAncestor(filePath) {
1795
+ const missing = [];
1796
+ let candidate = node_path.default.resolve(filePath);
1797
+ for (;;) try {
1798
+ const resolved = await node_fs_promises.default.realpath(candidate);
1799
+ return node_path.default.join(resolved, ...missing);
1800
+ } catch (error) {
1801
+ const code = error.code;
1802
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
1803
+ const parent = node_path.default.dirname(candidate);
1804
+ if (parent === candidate) return node_path.default.resolve(filePath);
1805
+ missing.unshift(node_path.default.basename(candidate));
1806
+ candidate = parent;
1807
+ }
1808
+ }
1809
+ async function pathExists(filePath) {
1810
+ return node_fs_promises.default.stat(filePath).then(() => true, (error) => {
1811
+ if (error.code === "ENOENT") return false;
1812
+ throw error;
1813
+ });
1814
+ }
1815
+ async function assertNoSymlinks(dirPath) {
1816
+ for (const entry of await node_fs_promises.default.readdir(dirPath, { withFileTypes: true })) {
1817
+ const entryPath = node_path.default.join(dirPath, entry.name);
1818
+ if (entry.isSymbolicLink()) throw new Error(`Symbolic links are not supported in project directories: ${entryPath}`);
1819
+ if (entry.isDirectory()) await assertNoSymlinks(entryPath);
1820
+ }
1821
+ }
1822
+ function createStagedNodeFileSystem(projectRoot, stagingRoot) {
1823
+ const { runProjectWriteTransaction: _, ...base } = createNodeBackendFileSystem();
1824
+ const translate = (filePath) => {
1825
+ const relative = node_path.default.relative(projectRoot, node_path.default.resolve(filePath));
1826
+ if (relative.startsWith("..") || node_path.default.isAbsolute(relative)) {
1827
+ const error = /* @__PURE__ */ new Error(`Project path escapes the staged root: ${filePath}`);
1828
+ error.code = "EACCES";
1829
+ throw error;
1830
+ }
1831
+ return node_path.default.join(stagingRoot, relative);
1832
+ };
1833
+ return {
1834
+ ...base,
1835
+ stat: (filePath) => node_fs_promises.default.stat(translate(filePath)),
1836
+ async readdir(dirPath) {
1837
+ const entries = await node_fs_promises.default.readdir(translate(dirPath), { withFileTypes: true });
1838
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
1839
+ if (symlink) throw new Error(`Symbolic links are not supported in project directories: ${node_path.default.join(dirPath, symlink.name)}`);
1840
+ return entries.map((entry) => entry.name);
1841
+ },
1842
+ readFile: (filePath) => node_fs_promises.default.readFile(translate(filePath), "utf-8"),
1843
+ async readFileRaw(filePath) {
1844
+ const buffer = await node_fs_promises.default.readFile(translate(filePath));
1845
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
1846
+ },
1847
+ writeFile: (filePath, content) => node_fs_promises.default.writeFile(translate(filePath), content, "utf-8"),
1848
+ writeFileRaw: (filePath, data) => node_fs_promises.default.writeFile(translate(filePath), data),
1849
+ async mkdir(dirPath, options) {
1850
+ await node_fs_promises.default.mkdir(translate(dirPath), { recursive: options?.recursive ?? false });
1851
+ },
1852
+ resolvePath: (filePath) => resolvePathThroughExistingAncestor(translate(filePath)),
1853
+ unlink: (filePath) => node_fs_promises.default.unlink(translate(filePath)),
1854
+ rmdir: (dirPath) => node_fs_promises.default.rmdir(translate(dirPath))
1855
+ };
1856
+ }
1857
+ async function runNodeProjectWriteTransaction(projectRoot, write) {
1858
+ const root = node_path.default.resolve(projectRoot);
1859
+ const parent = node_path.default.dirname(root);
1860
+ const name = node_path.default.basename(root);
1861
+ const staging = node_path.default.join(parent, `.${name}.save-${(0, node_crypto.randomUUID)()}`);
1862
+ const backup = node_path.default.join(parent, `.${name}.save-backup-${(0, node_crypto.randomUUID)()}`);
1863
+ const existed = await pathExists(root);
1864
+ if (existed) {
1865
+ await assertNoSymlinks(root);
1866
+ await node_fs_promises.default.cp(root, staging, {
1867
+ recursive: true,
1868
+ errorOnExist: true,
1869
+ force: false
1870
+ });
1871
+ } else await node_fs_promises.default.mkdir(staging, { recursive: true });
1872
+ try {
1873
+ await write(createStagedNodeFileSystem(root, staging));
1874
+ } catch (error) {
1875
+ await node_fs_promises.default.rm(staging, {
1876
+ recursive: true,
1877
+ force: true
1878
+ });
1879
+ throw error;
1880
+ }
1881
+ if (!existed) {
1882
+ await node_fs_promises.default.rename(staging, root);
1883
+ return;
1884
+ }
1885
+ await node_fs_promises.default.rename(root, backup);
1886
+ try {
1887
+ await node_fs_promises.default.rename(staging, root);
1888
+ } catch (error) {
1889
+ await node_fs_promises.default.rename(backup, root);
1890
+ await node_fs_promises.default.rm(staging, {
1891
+ recursive: true,
1892
+ force: true
1893
+ });
1894
+ throw error;
1895
+ }
1896
+ await node_fs_promises.default.rm(backup, {
1897
+ recursive: true,
1898
+ force: true
1899
+ }).catch(() => void 0);
1900
+ }
1697
1901
  function createNodeBackendFileSystem() {
1698
1902
  return {
1699
1903
  stat(filePath) {
1700
1904
  return node_fs_promises.default.stat(filePath);
1701
1905
  },
1702
- readdir(dirPath) {
1703
- return node_fs_promises.default.readdir(dirPath);
1906
+ async readdir(dirPath) {
1907
+ const entries = await node_fs_promises.default.readdir(dirPath, { withFileTypes: true });
1908
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
1909
+ if (symlink) {
1910
+ const error = /* @__PURE__ */ new Error(`Symbolic links are not supported in project directories: ${node_path.default.join(dirPath, symlink.name)}`);
1911
+ error.code = "ELOOP";
1912
+ throw error;
1913
+ }
1914
+ return entries.map((entry) => entry.name);
1704
1915
  },
1705
1916
  readFile(filePath) {
1706
1917
  return node_fs_promises.default.readFile(filePath, "utf-8");
@@ -1719,16 +1930,32 @@ function createNodeBackendFileSystem() {
1719
1930
  await node_fs_promises.default.mkdir(dirPath, { recursive: options?.recursive ?? false });
1720
1931
  },
1721
1932
  async resolvePath(filePath) {
1722
- try {
1723
- return await node_fs_promises.default.realpath(filePath);
1724
- } catch {
1725
- return node_path.default.resolve(filePath);
1726
- }
1933
+ return resolvePathThroughExistingAncestor(filePath);
1727
1934
  },
1935
+ validateProjectRoot: assertNoSymlinks,
1936
+ getSessionLockPath(canonicalProjectPath) {
1937
+ return node_path.default.join(node_path.default.dirname(canonicalProjectPath), `.${node_path.default.basename(canonicalProjectPath)}.openfairygui.backend.lock`);
1938
+ },
1939
+ runProjectWriteTransaction: runNodeProjectWriteTransaction,
1728
1940
  async acquireSessionLock(filePath) {
1729
- const handle = await node_fs_promises.default.open(filePath, "wx");
1941
+ let handle;
1942
+ try {
1943
+ handle = await node_fs_promises.default.open(filePath, "wx");
1944
+ } catch (error) {
1945
+ if (error.code !== "EEXIST" || !await recoverStaleLock(filePath)) throw error;
1946
+ handle = await node_fs_promises.default.open(filePath, "wx");
1947
+ }
1948
+ const owner = {
1949
+ schemaVersion: 1,
1950
+ pid: process.pid,
1951
+ processStartTime: PROCESS_START_TIME,
1952
+ hostname: node_os.default.hostname(),
1953
+ token: (0, node_crypto.randomUUID)(),
1954
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1955
+ };
1730
1956
  let closed = false;
1731
1957
  let released = false;
1958
+ let metadataWritten = false;
1732
1959
  const closeHandle = async () => {
1733
1960
  if (closed) return;
1734
1961
  await handle.close();
@@ -1736,13 +1963,23 @@ function createNodeBackendFileSystem() {
1736
1963
  };
1737
1964
  return {
1738
1965
  async writeMetadata(content) {
1739
- await handle.writeFile(content, "utf-8");
1966
+ let supplied = {};
1967
+ try {
1968
+ const parsed = JSON.parse(content);
1969
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) supplied = parsed;
1970
+ } catch {}
1971
+ await handle.writeFile(JSON.stringify({
1972
+ ...supplied,
1973
+ ...owner
1974
+ }, null, 2), "utf-8");
1975
+ metadataWritten = true;
1740
1976
  await closeHandle();
1741
1977
  },
1742
1978
  async release() {
1743
1979
  if (released) return;
1744
1980
  await closeHandle();
1745
- await node_fs_promises.default.unlink(filePath).catch((error) => {
1981
+ const current = metadataWritten ? parseLockMetadata(await node_fs_promises.default.readFile(filePath, "utf-8").catch(() => "")) : null;
1982
+ if (!metadataWritten || current?.token === owner.token) await node_fs_promises.default.unlink(filePath).catch((error) => {
1746
1983
  if (error.code !== "ENOENT") throw error;
1747
1984
  });
1748
1985
  released = true;
@@ -1768,11 +2005,7 @@ function createNodeBackendFileSystem() {
1768
2005
  }
1769
2006
  function createNodeBackendHostAdapter() {
1770
2007
  return { lockMetadata(input) {
1771
- return {
1772
- pid: process.pid,
1773
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1774
- canonicalPathKey: input.canonicalPathKey
1775
- };
2008
+ return { canonicalPathKey: input.canonicalPathKey };
1776
2009
  } };
1777
2010
  }
1778
2011
  function createNodeBackendRuntime(options = {}) {
package/dist/node.d.cts CHANGED
@@ -72,6 +72,12 @@ interface BackendFileSystem {
72
72
  recursive?: boolean;
73
73
  }): Promise<void>;
74
74
  resolvePath(filePath: string): Promise<string>;
75
+ /** Optional host validation before a project is read. Node rejects links anywhere in the project tree. */
76
+ validateProjectRoot?(projectRoot: string): Promise<void>;
77
+ /** Optional host-specific lock location. Node keeps it beside the project so directory swaps do not move it. */
78
+ getSessionLockPath?(canonicalProjectPath: string): string;
79
+ /** Runs project writes against a staged copy and commits them as one directory swap. */
80
+ runProjectWriteTransaction?(projectRoot: string, write: (stagedFileSystem: BackendFileSystem) => Promise<void>): Promise<void>;
75
81
  acquireSessionLock(lockPath: string): Promise<BackendSessionLock>;
76
82
  unlink(filePath: string): Promise<void>;
77
83
  rmdir(dirPath: string): Promise<void>;
@@ -163,7 +169,7 @@ interface BackendCapabilities {
163
169
  sessionRuntime: true;
164
170
  advisoryLocking: true;
165
171
  coordinatedSave: true;
166
- atomicSave: false;
172
+ atomicSave: boolean;
167
173
  staleRevisionProtection: true;
168
174
  pathPolicy: {
169
175
  canonicalization: 'realpath+normalized-casefold';
@@ -297,6 +303,11 @@ interface AdvisoryLockConflictError {
297
303
  holderSessionId?: string;
298
304
  lockFilePath: string;
299
305
  }
306
+ interface SessionIdConflictError {
307
+ code: 'session_id_conflict';
308
+ message: string;
309
+ sessionId: string;
310
+ }
300
311
  interface SavePartialFailureError {
301
312
  code: 'save_partial_failure';
302
313
  message: string;
@@ -306,7 +317,7 @@ interface SavePartialFailureError {
306
317
  lastSavedRevision: number;
307
318
  committedPaths: string[];
308
319
  failedPaths: string[];
309
- diskMayBePartiallyUpdated: true;
320
+ diskMayBePartiallyUpdated: boolean;
310
321
  }
311
322
  interface UamFidelityUnsupportedError {
312
323
  code: 'uam_fidelity_unsupported';
@@ -333,7 +344,7 @@ interface MaterializeWriteFailedError {
333
344
  failedPaths: string[];
334
345
  skippedPaths: string[];
335
346
  diagnostics: BackendDiagnostic[];
336
- diskMayBePartiallyUpdated: true;
347
+ diskMayBePartiallyUpdated: boolean;
337
348
  }
338
349
  type BackendEventKind = 'session.opened' | 'transaction.applied' | 'transaction.rejected' | 'save.started' | 'save.completed' | 'save.failed' | 'session.closeRequested' | 'session.closed' | 'cache.invalidated' | 'cache.updated' | 'job.created' | 'job.started' | 'job.progress' | 'job.cancelRequested' | 'job.cancelled' | 'job.completed' | 'job.failed';
339
350
  interface BackendEvent {
@@ -465,7 +476,17 @@ interface RefreshCacheInput {
465
476
  sessionId: string;
466
477
  reason?: 'manual' | 'session_open' | 'after_save';
467
478
  }
468
- type BackendError = SessionNotFoundError | SessionStaleWriteError | InProcessLockConflictError | AdvisoryLockConflictError | SavePartialFailureError | UamFidelityUnsupportedError | MaterializeValidationFailedError | MaterializeWriteFailedError | PathPolicyViolationError | EventCursorInvalidError | BackendJobNotFoundError | BackendJobNotCancellableError | BackendJobCancelledError | CacheRefreshFailedError | BackendCapabilityUnavailableError | ApplyUamTransactionAppError;
479
+ type BackendError = SessionNotFoundError | SessionIdConflictError | SessionStaleWriteError | InProcessLockConflictError | AdvisoryLockConflictError | SavePartialFailureError | UamFidelityUnsupportedError | MaterializeValidationFailedError | MaterializeWriteFailedError | PathPolicyViolationError | EventCursorInvalidError | BackendJobNotFoundError | BackendJobNotCancellableError | BackendJobCancelledError | CacheRefreshFailedError | BackendCapabilityUnavailableError | ProjectRootNotAllowedError | ProjectOpenFailedError | ApplyUamTransactionAppError;
480
+ interface ProjectRootNotAllowedError {
481
+ code: 'project_root_not_allowed';
482
+ message: string;
483
+ projectPath: string;
484
+ }
485
+ interface ProjectOpenFailedError {
486
+ code: 'project_open_failed';
487
+ message: string;
488
+ projectPath: string;
489
+ }
469
490
  interface ApplySessionTransactionInput {
470
491
  sessionId: string;
471
492
  expectedRevision: number;
@@ -512,6 +533,8 @@ interface MaterializeSessionInput {
512
533
  interface BackendRuntimeOptions {
513
534
  fileSystem?: BackendFileSystem;
514
535
  host?: BackendHostAdapter;
536
+ /** Canonical filesystem roots available to file-backed sessions. Omit for unrestricted library use. */
537
+ allowedProjectRoots?: readonly string[];
515
538
  }
516
539
  //#endregion
517
540
  //#region src/runtime.d.ts
@@ -535,8 +558,8 @@ declare class BackendRuntime {
535
558
  getCapabilities(): BackendSuccess<BackendCapabilities>;
536
559
  openSession(input: {
537
560
  projectPath: string;
538
- }): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError>>;
539
- openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot>;
561
+ }): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError | ProjectRootNotAllowedError | ProjectOpenFailedError>>;
562
+ openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot, InProcessLockConflictError | SessionIdConflictError>;
540
563
  getSession(input: {
541
564
  sessionId: string;
542
565
  }): BackendResult<BackendSessionSnapshot, SessionNotFoundError>;