@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/index.mjs CHANGED
@@ -41,6 +41,16 @@ function createRuntimePathPolicy() {
41
41
  workspaceBoundary: "project-root-only"
42
42
  };
43
43
  }
44
+ async function assertProjectPathContained(fileSystem, projectRoot, targetPath) {
45
+ const [resolvedRoot, resolvedTarget] = await Promise.all([fileSystem.resolvePath(projectRoot), fileSystem.resolvePath(targetPath)]);
46
+ const root = normalizeComparablePath(resolvedRoot);
47
+ const target = normalizeComparablePath(resolvedTarget);
48
+ if (root === "." && !target.startsWith("/") && !/^[a-z]:\//i.test(target)) return;
49
+ if (target === root || target.startsWith(`${root}/`)) return;
50
+ const error = /* @__PURE__ */ new Error(`Project path escapes the opened root: ${targetPath}`);
51
+ error.code = "EACCES";
52
+ throw error;
53
+ }
44
54
  async function resolveFairyPath(fileSystem, input) {
45
55
  const resolvedInput = fileSystem.resolve(input);
46
56
  const stat = await fileSystem.stat(resolvedInput);
@@ -121,10 +131,14 @@ function failure(stage, startedAt, error, session, options) {
121
131
  }
122
132
  //#endregion
123
133
  //#region src/services/session-project-writer.ts
124
- function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
134
+ function createWriterFileSystem(fileSystem, projectRoot, writtenPaths, failedPaths) {
135
+ const contained = async (targetPath, operation) => {
136
+ await assertProjectPathContained(fileSystem, projectRoot, targetPath);
137
+ return operation();
138
+ };
125
139
  async function trackWrite(targetPath, write) {
126
140
  try {
127
- const result = await write();
141
+ const result = await contained(targetPath, write);
128
142
  writtenPaths.push(targetPath);
129
143
  return result;
130
144
  } catch (error) {
@@ -133,8 +147,8 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
133
147
  }
134
148
  }
135
149
  return {
136
- readFile: (path) => fileSystem.readFile(path),
137
- readFileRaw: (path) => fileSystem.readFileRaw(path),
150
+ readFile: (path) => contained(path, () => fileSystem.readFile(path)),
151
+ readFileRaw: (path) => contained(path, () => fileSystem.readFileRaw(path)),
138
152
  writeFile: (path, content) => trackWrite(path, async () => {
139
153
  await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
140
154
  await fileSystem.writeFile(path, content);
@@ -143,9 +157,10 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
143
157
  await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
144
158
  await fileSystem.writeFileRaw(path, data);
145
159
  }),
146
- mkdir: (path) => fileSystem.mkdir(path, { recursive: true }),
147
- readdir: (path) => fileSystem.readdir(path),
160
+ mkdir: (path) => contained(path, () => fileSystem.mkdir(path, { recursive: true })),
161
+ readdir: (path) => contained(path, () => fileSystem.readdir(path)),
148
162
  async exists(path) {
163
+ await assertProjectPathContained(fileSystem, projectRoot, path);
149
164
  try {
150
165
  await fileSystem.stat(path);
151
166
  return true;
@@ -160,11 +175,21 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
160
175
  };
161
176
  }
162
177
  async function writeSessionProject(input) {
163
- await new ProjectWriter(createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
164
- staleSourceFiles: input.staleSourceFiles,
165
- staleResourceFolders: input.staleResourceFolders,
166
- staleBranchDirectories: input.staleBranchDirectories
167
- });
178
+ const projectRoot = input.fileSystem.dirname(input.fairyPath);
179
+ const write = async (fileSystem) => {
180
+ await new ProjectWriter(createWriterFileSystem(fileSystem, projectRoot, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
181
+ staleSourceFiles: input.staleSourceFiles,
182
+ staleResourceFolders: input.staleResourceFolders,
183
+ staleBranchDirectories: input.staleBranchDirectories
184
+ });
185
+ };
186
+ if (!input.fileSystem.runProjectWriteTransaction) return write(input.fileSystem);
187
+ try {
188
+ await input.fileSystem.runProjectWriteTransaction(projectRoot, write);
189
+ } catch (error) {
190
+ input.writtenPaths.length = 0;
191
+ throw error;
192
+ }
168
193
  }
169
194
  //#endregion
170
195
  //#region src/services/session-utils.ts
@@ -478,7 +503,7 @@ var AuthoringService = class {
478
503
  lastSavedRevision: session.lastSavedRevision,
479
504
  committedPaths,
480
505
  failedPaths,
481
- diskMayBePartiallyUpdated: true
506
+ diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction
482
507
  }, toSessionSnapshot(session, this.context.capabilities), {
483
508
  sessionId: session.sessionId,
484
509
  revision: session.revision
@@ -653,7 +678,7 @@ var AuthoringService = class {
653
678
  failedPaths,
654
679
  skippedPaths,
655
680
  diagnostics: diagnosticsFromError,
656
- diskMayBePartiallyUpdated: true
681
+ diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction
657
682
  }, toSessionSnapshot(session, this.context.capabilities), {
658
683
  sessionId: session.sessionId,
659
684
  revision: session.revision,
@@ -1142,7 +1167,7 @@ var ReadService = class {
1142
1167
  //#endregion
1143
1168
  //#region src/services/runtime-service.ts
1144
1169
  function randomId() {
1145
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1170
+ return crypto.randomUUID();
1146
1171
  }
1147
1172
  function createCapabilityUnavailableError(capability) {
1148
1173
  const artifactCapability = capability === "artifact.publish" || capability === "artifact.restore";
@@ -1155,29 +1180,33 @@ function createCapabilityUnavailableError(capability) {
1155
1180
  bridgeBoundary: artifactCapability ? "external-bridge" : void 0
1156
1181
  };
1157
1182
  }
1158
- function createProjectReaderFileSystem(fileSystem) {
1183
+ function createProjectReaderFileSystem(fileSystem, projectRoot) {
1184
+ const contained = async (filePath, operation) => {
1185
+ await assertProjectPathContained(fileSystem, projectRoot, filePath);
1186
+ return operation();
1187
+ };
1159
1188
  return {
1160
1189
  readFile(filePath) {
1161
- return fileSystem.readFile(filePath);
1190
+ return contained(filePath, () => fileSystem.readFile(filePath));
1162
1191
  },
1163
1192
  readFileRaw(filePath) {
1164
- return fileSystem.readFileRaw(filePath);
1193
+ return contained(filePath, () => fileSystem.readFileRaw(filePath));
1165
1194
  },
1166
1195
  writeFile(filePath, content) {
1167
- return fileSystem.writeFile(filePath, content);
1196
+ return contained(filePath, () => fileSystem.writeFile(filePath, content));
1168
1197
  },
1169
1198
  writeFileRaw(filePath, data) {
1170
- return fileSystem.writeFileRaw(filePath, data);
1199
+ return contained(filePath, () => fileSystem.writeFileRaw(filePath, data));
1171
1200
  },
1172
1201
  async mkdir(dirPath) {
1173
- await fileSystem.mkdir(dirPath, { recursive: true });
1202
+ await contained(dirPath, () => fileSystem.mkdir(dirPath, { recursive: true }));
1174
1203
  },
1175
1204
  readdir(dirPath) {
1176
- return fileSystem.readdir(dirPath);
1205
+ return contained(dirPath, () => fileSystem.readdir(dirPath));
1177
1206
  },
1178
1207
  async exists(filePath) {
1179
1208
  try {
1180
- await fileSystem.stat(filePath);
1209
+ await contained(filePath, () => fileSystem.stat(filePath));
1181
1210
  return true;
1182
1211
  } catch {
1183
1212
  return false;
@@ -1271,9 +1300,25 @@ var RuntimeService = class {
1271
1300
  const startedAt = Date.now();
1272
1301
  if (!this.context.fileSystem) return failure("runtime", startedAt, createCapabilityUnavailableError("fileSystem"));
1273
1302
  const fileSystem = this.context.fileSystem;
1303
+ if (this.context.allowedProjectRoots?.length) {
1304
+ let allowed = false;
1305
+ for (const root of this.context.allowedProjectRoots) try {
1306
+ await assertProjectPathContained(fileSystem, root, input.projectPath);
1307
+ allowed = true;
1308
+ break;
1309
+ } catch (error) {
1310
+ if (error.code !== "EACCES") throw error;
1311
+ }
1312
+ if (!allowed) return failure("runtime", startedAt, {
1313
+ code: "project_root_not_allowed",
1314
+ message: "Project path is outside the configured allowed roots.",
1315
+ projectPath: input.projectPath
1316
+ });
1317
+ }
1274
1318
  const { fairyPath, canonicalProjectPath, canonicalPathKey } = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
1319
+ await fileSystem.validateProjectRoot?.(canonicalProjectPath);
1275
1320
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
1276
- const lockFilePath = fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
1321
+ const lockFilePath = fileSystem.getSessionLockPath?.(canonicalProjectPath) ?? fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
1277
1322
  if (existingSessionId) return failure("runtime", startedAt, {
1278
1323
  code: "lock_conflict",
1279
1324
  kind: "in_process_session_exists",
@@ -1293,7 +1338,7 @@ var RuntimeService = class {
1293
1338
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1294
1339
  canonicalPathKey
1295
1340
  }));
1296
- const read = await new ProjectReader(createProjectReaderFileSystem(fileSystem)).readDetailed(fairyPath, { hydrateResourceBytes: true });
1341
+ const read = await new ProjectReader(createProjectReaderFileSystem(fileSystem, canonicalProjectPath)).readDetailed(fairyPath, { hydrateResourceBytes: true });
1297
1342
  if (!read.document) throw new Error(read.diagnostics[0]?.message ?? `Unable to read project: ${fairyPath}`);
1298
1343
  const document = read.document;
1299
1344
  const project = liftDocumentToUamProject(document);
@@ -1351,6 +1396,11 @@ var RuntimeService = class {
1351
1396
  openProjectSession(input) {
1352
1397
  const startedAt = Date.now();
1353
1398
  const sessionId = input.sessionId ?? randomId();
1399
+ if (this.context.sessions.has(sessionId)) return failure("runtime", startedAt, {
1400
+ code: "session_id_conflict",
1401
+ message: `Session id is already in use: ${sessionId}`,
1402
+ sessionId
1403
+ });
1354
1404
  const storage = input.storage;
1355
1405
  const memoryProjectPath = `memory://${sessionId}`;
1356
1406
  const canonicalProjectPath = storage?.canonicalProjectPath ?? input.canonicalProjectPath ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || "." : memoryProjectPath);
@@ -1477,7 +1527,7 @@ const ARTIFACT_BRIDGE_CAPABILITY = {
1477
1527
  bridgeEntrypoint: "@openfairygui/backend/node",
1478
1528
  reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
1479
1529
  };
1480
- function createCapabilities() {
1530
+ function createCapabilities(atomicSave = false) {
1481
1531
  return {
1482
1532
  contractVersion: BACKEND_CONTRACT_VERSION,
1483
1533
  capabilitySchemaVersion: 3,
@@ -1549,7 +1599,7 @@ function createCapabilities() {
1549
1599
  sessionRuntime: true,
1550
1600
  advisoryLocking: true,
1551
1601
  coordinatedSave: true,
1552
- atomicSave: false,
1602
+ atomicSave,
1553
1603
  staleRevisionProtection: true,
1554
1604
  pathPolicy: createRuntimePathPolicy(),
1555
1605
  events: {
@@ -1595,10 +1645,11 @@ var BackendRuntime = class {
1595
1645
  jobService;
1596
1646
  constructor(options = {}) {
1597
1647
  this.fileSystem = options.fileSystem;
1598
- this.capabilities = createCapabilities();
1648
+ this.capabilities = createCapabilities(Boolean(options.fileSystem?.runProjectWriteTransaction));
1599
1649
  this.context = {
1600
1650
  fileSystem: this.fileSystem,
1601
1651
  host: options.host,
1652
+ allowedProjectRoots: options.allowedProjectRoots,
1602
1653
  capabilities: this.capabilities,
1603
1654
  sessions: this.sessions,
1604
1655
  sessionsByPath: this.sessionsByPath,
@@ -1621,7 +1672,16 @@ var BackendRuntime = class {
1621
1672
  return this.readService.getCapabilities();
1622
1673
  }
1623
1674
  async openSession(input) {
1624
- return this.runtimeService.openSession(input);
1675
+ const startedAt = Date.now();
1676
+ try {
1677
+ return await this.runtimeService.openSession(input);
1678
+ } catch {
1679
+ return failure("runtime", startedAt, {
1680
+ code: "project_open_failed",
1681
+ message: "Unable to open project.",
1682
+ projectPath: input.projectPath
1683
+ });
1684
+ }
1625
1685
  }
1626
1686
  openProjectSession(input) {
1627
1687
  return this.runtimeService.openProjectSession(input);
@@ -1645,7 +1705,7 @@ var BackendRuntime = class {
1645
1705
  return this.authoringService.materializeSession(input);
1646
1706
  }
1647
1707
  async closeSession(input) {
1648
- return this.runtimeService.closeSession(input);
1708
+ return this.authoringService.runSessionExclusive(input.sessionId, () => this.runtimeService.closeSession(input));
1649
1709
  }
1650
1710
  getEvents(input) {
1651
1711
  return this.eventService.getEvents(input);