@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/src/node.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
3
5
  import {
4
6
  BackendRuntime,
5
7
  type BackendFileStat,
@@ -9,13 +11,183 @@ import {
9
11
  type BackendSessionLock,
10
12
  } from './runtime.js';
11
13
 
14
+ const PROCESS_START_TIME = Math.trunc(Date.now() - process.uptime() * 1000);
15
+
16
+ interface NodeLockMetadata {
17
+ schemaVersion: 1;
18
+ pid: number;
19
+ processStartTime: number;
20
+ hostname: string;
21
+ token: string;
22
+ }
23
+
24
+ function parseLockMetadata(content: string): NodeLockMetadata | null {
25
+ try {
26
+ const value = JSON.parse(content) as Partial<NodeLockMetadata>;
27
+ if (
28
+ value.schemaVersion !== 1
29
+ || !Number.isSafeInteger(value.pid)
30
+ || !Number.isFinite(value.processStartTime)
31
+ || typeof value.hostname !== 'string'
32
+ || typeof value.token !== 'string'
33
+ ) return null;
34
+ return value as NodeLockMetadata;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function isProcessAlive(metadata: NodeLockMetadata): boolean {
41
+ if (metadata.hostname !== os.hostname()) return true;
42
+ if (metadata.pid === process.pid) return Math.abs(metadata.processStartTime - PROCESS_START_TIME) < 1000;
43
+ try {
44
+ process.kill(metadata.pid, 0);
45
+ return true;
46
+ } catch (error) {
47
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH';
48
+ }
49
+ }
50
+
51
+ async function recoverStaleLock(filePath: string): Promise<boolean> {
52
+ let before: string;
53
+ try {
54
+ before = await fs.readFile(filePath, 'utf-8');
55
+ } catch {
56
+ return false;
57
+ }
58
+ const metadata = parseLockMetadata(before);
59
+ if (!metadata || isProcessAlive(metadata)) return false;
60
+ const current = parseLockMetadata(await fs.readFile(filePath, 'utf-8').catch(() => ''));
61
+ if (!current || current.token !== metadata.token) return false;
62
+ await fs.unlink(filePath);
63
+ return true;
64
+ }
65
+
66
+ async function resolvePathThroughExistingAncestor(filePath: string): Promise<string> {
67
+ const missing: string[] = [];
68
+ let candidate = path.resolve(filePath);
69
+ for (;;) {
70
+ try {
71
+ const resolved = await fs.realpath(candidate);
72
+ return path.join(resolved, ...missing);
73
+ } catch (error) {
74
+ const code = (error as NodeJS.ErrnoException).code;
75
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
76
+ const parent = path.dirname(candidate);
77
+ if (parent === candidate) return path.resolve(filePath);
78
+ missing.unshift(path.basename(candidate));
79
+ candidate = parent;
80
+ }
81
+ }
82
+ }
83
+
84
+ async function pathExists(filePath: string): Promise<boolean> {
85
+ return fs.stat(filePath).then(
86
+ () => true,
87
+ (error: NodeJS.ErrnoException) => {
88
+ if (error.code === 'ENOENT') return false;
89
+ throw error;
90
+ },
91
+ );
92
+ }
93
+
94
+ async function assertNoSymlinks(dirPath: string): Promise<void> {
95
+ for (const entry of await fs.readdir(dirPath, { withFileTypes: true })) {
96
+ const entryPath = path.join(dirPath, entry.name);
97
+ if (entry.isSymbolicLink()) throw new Error(`Symbolic links are not supported in project directories: ${entryPath}`);
98
+ if (entry.isDirectory()) await assertNoSymlinks(entryPath);
99
+ }
100
+ }
101
+
102
+ function createStagedNodeFileSystem(projectRoot: string, stagingRoot: string): BackendFileSystem {
103
+ const { runProjectWriteTransaction: _, ...base } = createNodeBackendFileSystem();
104
+ const translate = (filePath: string): string => {
105
+ const relative = path.relative(projectRoot, path.resolve(filePath));
106
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
107
+ const error = new Error(`Project path escapes the staged root: ${filePath}`) as Error & { code: string };
108
+ error.code = 'EACCES';
109
+ throw error;
110
+ }
111
+ return path.join(stagingRoot, relative);
112
+ };
113
+ return {
114
+ ...base,
115
+ stat: (filePath) => fs.stat(translate(filePath)),
116
+ async readdir(dirPath) {
117
+ const entries = await fs.readdir(translate(dirPath), { withFileTypes: true });
118
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
119
+ if (symlink) throw new Error(`Symbolic links are not supported in project directories: ${path.join(dirPath, symlink.name)}`);
120
+ return entries.map((entry) => entry.name);
121
+ },
122
+ readFile: (filePath) => fs.readFile(translate(filePath), 'utf-8'),
123
+ async readFileRaw(filePath) {
124
+ const buffer = await fs.readFile(translate(filePath));
125
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
126
+ },
127
+ writeFile: (filePath, content) => fs.writeFile(translate(filePath), content, 'utf-8'),
128
+ writeFileRaw: (filePath, data) => fs.writeFile(translate(filePath), data),
129
+ async mkdir(dirPath, options) {
130
+ await fs.mkdir(translate(dirPath), { recursive: options?.recursive ?? false });
131
+ },
132
+ resolvePath: (filePath) => resolvePathThroughExistingAncestor(translate(filePath)),
133
+ unlink: (filePath) => fs.unlink(translate(filePath)),
134
+ rmdir: (dirPath) => fs.rmdir(translate(dirPath)),
135
+ };
136
+ }
137
+
138
+ async function runNodeProjectWriteTransaction(
139
+ projectRoot: string,
140
+ write: (stagedFileSystem: BackendFileSystem) => Promise<void>,
141
+ ): Promise<void> {
142
+ const root = path.resolve(projectRoot);
143
+ const parent = path.dirname(root);
144
+ const name = path.basename(root);
145
+ const staging = path.join(parent, `.${name}.save-${randomUUID()}`);
146
+ const backup = path.join(parent, `.${name}.save-backup-${randomUUID()}`);
147
+ const existed = await pathExists(root);
148
+ if (existed) {
149
+ await assertNoSymlinks(root);
150
+ await fs.cp(root, staging, { recursive: true, errorOnExist: true, force: false });
151
+ } else {
152
+ await fs.mkdir(staging, { recursive: true });
153
+ }
154
+ try {
155
+ await write(createStagedNodeFileSystem(root, staging));
156
+ } catch (error) {
157
+ await fs.rm(staging, { recursive: true, force: true });
158
+ throw error;
159
+ }
160
+ if (!existed) {
161
+ await fs.rename(staging, root);
162
+ return;
163
+ }
164
+
165
+ // ponytail: two-step rename preserves rollback; use directory exchange if zero reader gap becomes required.
166
+ await fs.rename(root, backup);
167
+ try {
168
+ await fs.rename(staging, root);
169
+ } catch (error) {
170
+ await fs.rename(backup, root);
171
+ await fs.rm(staging, { recursive: true, force: true });
172
+ throw error;
173
+ }
174
+ await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
175
+ }
176
+
12
177
  export function createNodeBackendFileSystem(): BackendFileSystem {
13
178
  return {
14
179
  stat(filePath: string): Promise<BackendFileStat> {
15
180
  return fs.stat(filePath);
16
181
  },
17
- readdir(dirPath: string): Promise<string[]> {
18
- return fs.readdir(dirPath);
182
+ async readdir(dirPath: string): Promise<string[]> {
183
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
184
+ const symlink = entries.find((entry) => entry.isSymbolicLink());
185
+ if (symlink) {
186
+ const error = new Error(`Symbolic links are not supported in project directories: ${path.join(dirPath, symlink.name)}`) as Error & { code: string };
187
+ error.code = 'ELOOP';
188
+ throw error;
189
+ }
190
+ return entries.map((entry) => entry.name);
19
191
  },
20
192
  readFile(filePath: string): Promise<string> {
21
193
  return fs.readFile(filePath, 'utf-8');
@@ -34,16 +206,32 @@ export function createNodeBackendFileSystem(): BackendFileSystem {
34
206
  await fs.mkdir(dirPath, { recursive: options?.recursive ?? false });
35
207
  },
36
208
  async resolvePath(filePath: string): Promise<string> {
37
- try {
38
- return await fs.realpath(filePath);
39
- } catch {
40
- return path.resolve(filePath);
41
- }
209
+ return resolvePathThroughExistingAncestor(filePath);
210
+ },
211
+ validateProjectRoot: assertNoSymlinks,
212
+ getSessionLockPath(canonicalProjectPath: string): string {
213
+ return path.join(path.dirname(canonicalProjectPath), `.${path.basename(canonicalProjectPath)}.openfairygui.backend.lock`);
42
214
  },
215
+ runProjectWriteTransaction: runNodeProjectWriteTransaction,
43
216
  async acquireSessionLock(filePath: string): Promise<BackendSessionLock> {
44
- const handle = await fs.open(filePath, 'wx');
217
+ let handle: Awaited<ReturnType<typeof fs.open>>;
218
+ try {
219
+ handle = await fs.open(filePath, 'wx');
220
+ } catch (error) {
221
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || !(await recoverStaleLock(filePath))) throw error;
222
+ handle = await fs.open(filePath, 'wx');
223
+ }
224
+ const owner = {
225
+ schemaVersion: 1 as const,
226
+ pid: process.pid,
227
+ processStartTime: PROCESS_START_TIME,
228
+ hostname: os.hostname(),
229
+ token: randomUUID(),
230
+ createdAt: new Date().toISOString(),
231
+ };
45
232
  let closed = false;
46
233
  let released = false;
234
+ let metadataWritten = false;
47
235
  const closeHandle = async (): Promise<void> => {
48
236
  if (closed) return;
49
237
  await handle.close();
@@ -51,15 +239,28 @@ export function createNodeBackendFileSystem(): BackendFileSystem {
51
239
  };
52
240
  return {
53
241
  async writeMetadata(content: string): Promise<void> {
54
- await handle.writeFile(content, 'utf-8');
242
+ let supplied: Record<string, unknown> = {};
243
+ try {
244
+ const parsed = JSON.parse(content) as unknown;
245
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) supplied = parsed as Record<string, unknown>;
246
+ } catch {
247
+ // Host metadata is optional; ownership metadata remains authoritative.
248
+ }
249
+ await handle.writeFile(JSON.stringify({ ...supplied, ...owner }, null, 2), 'utf-8');
250
+ metadataWritten = true;
55
251
  await closeHandle();
56
252
  },
57
253
  async release(): Promise<void> {
58
254
  if (released) return;
59
255
  await closeHandle();
60
- await fs.unlink(filePath).catch((error: NodeJS.ErrnoException) => {
61
- if (error.code !== 'ENOENT') throw error;
62
- });
256
+ const current = metadataWritten
257
+ ? parseLockMetadata(await fs.readFile(filePath, 'utf-8').catch(() => ''))
258
+ : null;
259
+ if (!metadataWritten || current?.token === owner.token) {
260
+ await fs.unlink(filePath).catch((error: NodeJS.ErrnoException) => {
261
+ if (error.code !== 'ENOENT') throw error;
262
+ });
263
+ }
63
264
  released = true;
64
265
  },
65
266
  };
@@ -86,8 +287,6 @@ export function createNodeBackendHostAdapter(): BackendHostAdapter {
86
287
  return {
87
288
  lockMetadata(input) {
88
289
  return {
89
- pid: process.pid,
90
- createdAt: new Date().toISOString(),
91
290
  canonicalPathKey: input.canonicalPathKey,
92
291
  };
93
292
  },
@@ -41,6 +41,24 @@ export function createRuntimePathPolicy(): BackendCapabilities['runtime']['pathP
41
41
  };
42
42
  }
43
43
 
44
+ export async function assertProjectPathContained(
45
+ fileSystem: BackendFileSystem,
46
+ projectRoot: string,
47
+ targetPath: string,
48
+ ): Promise<void> {
49
+ const [resolvedRoot, resolvedTarget] = await Promise.all([
50
+ fileSystem.resolvePath(projectRoot),
51
+ fileSystem.resolvePath(targetPath),
52
+ ]);
53
+ const root = normalizeComparablePath(resolvedRoot);
54
+ const target = normalizeComparablePath(resolvedTarget);
55
+ if (root === '.' && !target.startsWith('/') && !/^[a-z]:\//i.test(target)) return;
56
+ if (target === root || target.startsWith(`${root}/`)) return;
57
+ const error = new Error(`Project path escapes the opened root: ${targetPath}`) as Error & { code: string };
58
+ error.code = 'EACCES';
59
+ throw error;
60
+ }
61
+
44
62
  export async function resolveFairyPath(fileSystem: BackendFileSystem, input: string): Promise<string> {
45
63
  const resolvedInput = fileSystem.resolve(input);
46
64
  const stat = await fileSystem.stat(resolvedInput);
@@ -38,7 +38,7 @@ const ARTIFACT_BRIDGE_CAPABILITY = {
38
38
  reason: 'publish/restore require explicit Node-hosted filesystem and artifact execution.',
39
39
  } as const satisfies BackendArtifactBridgeCapability;
40
40
 
41
- export function createCapabilities(): BackendCapabilities {
41
+ export function createCapabilities(atomicSave = false): BackendCapabilities {
42
42
  return {
43
43
  contractVersion: BACKEND_CONTRACT_VERSION,
44
44
  capabilitySchemaVersion: BACKEND_CAPABILITY_SCHEMA_VERSION,
@@ -102,7 +102,7 @@ export function createCapabilities(): BackendCapabilities {
102
102
  sessionRuntime: true,
103
103
  advisoryLocking: true,
104
104
  coordinatedSave: true,
105
- atomicSave: false,
105
+ atomicSave,
106
106
  staleRevisionProtection: true,
107
107
  pathPolicy: createRuntimePathPolicy(),
108
108
  events: {
@@ -36,6 +36,15 @@ export interface BackendFileSystem {
36
36
  writeFileRaw(filePath: string, data: Uint8Array): Promise<void>;
37
37
  mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void>;
38
38
  resolvePath(filePath: string): Promise<string>;
39
+ /** Optional host validation before a project is read. Node rejects links anywhere in the project tree. */
40
+ validateProjectRoot?(projectRoot: string): Promise<void>;
41
+ /** Optional host-specific lock location. Node keeps it beside the project so directory swaps do not move it. */
42
+ getSessionLockPath?(canonicalProjectPath: string): string;
43
+ /** Runs project writes against a staged copy and commits them as one directory swap. */
44
+ runProjectWriteTransaction?(
45
+ projectRoot: string,
46
+ write: (stagedFileSystem: BackendFileSystem) => Promise<void>,
47
+ ): Promise<void>;
39
48
  acquireSessionLock(lockPath: string): Promise<BackendSessionLock>;
40
49
  unlink(filePath: string): Promise<void>;
41
50
  rmdir(dirPath: string): Promise<void>;
@@ -144,7 +153,7 @@ export interface BackendCapabilities {
144
153
  sessionRuntime: true;
145
154
  advisoryLocking: true;
146
155
  coordinatedSave: true;
147
- atomicSave: false;
156
+ atomicSave: boolean;
148
157
  staleRevisionProtection: true;
149
158
  pathPolicy: {
150
159
  canonicalization: 'realpath+normalized-casefold';
@@ -276,6 +285,12 @@ export interface AdvisoryLockConflictError {
276
285
  lockFilePath: string;
277
286
  }
278
287
 
288
+ export interface SessionIdConflictError {
289
+ code: 'session_id_conflict';
290
+ message: string;
291
+ sessionId: string;
292
+ }
293
+
279
294
  export interface SavePartialFailureError {
280
295
  code: 'save_partial_failure';
281
296
  message: string;
@@ -285,7 +300,7 @@ export interface SavePartialFailureError {
285
300
  lastSavedRevision: number;
286
301
  committedPaths: string[];
287
302
  failedPaths: string[];
288
- diskMayBePartiallyUpdated: true;
303
+ diskMayBePartiallyUpdated: boolean;
289
304
  }
290
305
 
291
306
  export interface UamFidelityUnsupportedError {
@@ -315,7 +330,7 @@ export interface MaterializeWriteFailedError {
315
330
  failedPaths: string[];
316
331
  skippedPaths: string[];
317
332
  diagnostics: BackendDiagnostic[];
318
- diskMayBePartiallyUpdated: true;
333
+ diskMayBePartiallyUpdated: boolean;
319
334
  }
320
335
 
321
336
  export type BackendEventKind =
@@ -494,6 +509,7 @@ export interface RefreshCacheInput {
494
509
 
495
510
  export type BackendError =
496
511
  | SessionNotFoundError
512
+ | SessionIdConflictError
497
513
  | SessionStaleWriteError
498
514
  | InProcessLockConflictError
499
515
  | AdvisoryLockConflictError
@@ -508,8 +524,22 @@ export type BackendError =
508
524
  | BackendJobCancelledError
509
525
  | CacheRefreshFailedError
510
526
  | BackendCapabilityUnavailableError
527
+ | ProjectRootNotAllowedError
528
+ | ProjectOpenFailedError
511
529
  | ApplyUamTransactionAppError;
512
530
 
531
+ export interface ProjectRootNotAllowedError {
532
+ code: 'project_root_not_allowed';
533
+ message: string;
534
+ projectPath: string;
535
+ }
536
+
537
+ export interface ProjectOpenFailedError {
538
+ code: 'project_open_failed';
539
+ message: string;
540
+ projectPath: string;
541
+ }
542
+
513
543
  export interface ApplySessionTransactionInput {
514
544
  sessionId: string;
515
545
  expectedRevision: number;
@@ -563,4 +593,6 @@ export interface MaterializeSessionInput {
563
593
  export interface BackendRuntimeOptions {
564
594
  fileSystem?: BackendFileSystem;
565
595
  host?: BackendHostAdapter;
596
+ /** Canonical filesystem roots available to file-backed sessions. Omit for unrestricted library use. */
597
+ allowedProjectRoots?: readonly string[];
566
598
  }
package/src/runtime.ts CHANGED
@@ -3,7 +3,7 @@ import type { ProjectValidationReport } from '@openfairygui/core';
3
3
  import type { PathPolicyViolationError } from './path-policy.js';
4
4
  import { AuthoringService } from './services/authoring-service.js';
5
5
  import { CacheService } from './services/cache-service.js';
6
- import type { BackendContext, BackendSessionState } from './services/context.js';
6
+ import { type BackendContext, type BackendSessionState, failure } from './services/context.js';
7
7
  import { EventService } from './services/event-service.js';
8
8
  import { JobService } from './services/job-service.js';
9
9
  import { ReadService } from './services/read-service.js';
@@ -42,9 +42,12 @@ import type {
42
42
  MaterializeValidationFailedError,
43
43
  MaterializeWriteFailedError,
44
44
  OpenProjectSessionInput,
45
+ ProjectOpenFailedError,
46
+ ProjectRootNotAllowedError,
45
47
  RefreshCacheInput,
46
48
  SavePartialFailureError,
47
49
  SaveSessionInput,
50
+ SessionIdConflictError,
48
51
  SessionNotFoundError,
49
52
  SessionStaleWriteError,
50
53
  UamFidelityUnsupportedError,
@@ -71,10 +74,11 @@ export class BackendRuntime {
71
74
 
72
75
  public constructor(options: BackendRuntimeOptions = {}) {
73
76
  this.fileSystem = options.fileSystem;
74
- this.capabilities = createCapabilities();
77
+ this.capabilities = createCapabilities(Boolean(options.fileSystem?.runProjectWriteTransaction));
75
78
  this.context = {
76
79
  fileSystem: this.fileSystem,
77
80
  host: options.host,
81
+ allowedProjectRoots: options.allowedProjectRoots,
78
82
  capabilities: this.capabilities,
79
83
  sessions: this.sessions,
80
84
  sessionsByPath: this.sessionsByPath,
@@ -103,13 +107,28 @@ export class BackendRuntime {
103
107
  }): Promise<
104
108
  BackendResult<
105
109
  BackendSessionSnapshot,
106
- InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError
110
+ InProcessLockConflictError
111
+ | AdvisoryLockConflictError
112
+ | BackendCapabilityUnavailableError
113
+ | ProjectRootNotAllowedError
114
+ | ProjectOpenFailedError
107
115
  >
108
116
  > {
109
- return this.runtimeService.openSession(input);
110
- }
111
-
112
- public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
117
+ const startedAt = Date.now();
118
+ try {
119
+ return await this.runtimeService.openSession(input);
120
+ } catch {
121
+ return failure('runtime', startedAt, {
122
+ code: 'project_open_failed',
123
+ message: 'Unable to open project.',
124
+ projectPath: input.projectPath,
125
+ });
126
+ }
127
+ }
128
+
129
+ public openProjectSession(
130
+ input: OpenProjectSessionInput,
131
+ ): BackendResult<BackendSessionSnapshot, InProcessLockConflictError | SessionIdConflictError> {
113
132
  return this.runtimeService.openProjectSession(input);
114
133
  }
115
134
 
@@ -180,7 +199,7 @@ export class BackendRuntime {
180
199
  public async closeSession(input: {
181
200
  sessionId: string;
182
201
  }): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
183
- return this.runtimeService.closeSession(input);
202
+ return this.authoringService.runSessionExclusive(input.sessionId, () => this.runtimeService.closeSession(input));
184
203
  }
185
204
 
186
205
  public getEvents(
@@ -178,7 +178,7 @@ export class AuthoringService {
178
178
  private readonly eventService: EventService,
179
179
  ) {}
180
180
 
181
- private async runSessionExclusive<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
181
+ public async runSessionExclusive<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
182
182
  const previous = this.sessionOperations.get(sessionId) ?? Promise.resolve();
183
183
  let release = (): void => undefined;
184
184
  const current = new Promise<void>((resolve) => {
@@ -465,7 +465,7 @@ export class AuthoringService {
465
465
  lastSavedRevision: session.lastSavedRevision,
466
466
  committedPaths,
467
467
  failedPaths,
468
- diskMayBePartiallyUpdated: true,
468
+ diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction,
469
469
  },
470
470
  toSessionSnapshot(session, this.context.capabilities),
471
471
  {
@@ -744,7 +744,7 @@ export class AuthoringService {
744
744
  failedPaths,
745
745
  skippedPaths,
746
746
  diagnostics: diagnosticsFromError,
747
- diskMayBePartiallyUpdated: true,
747
+ diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction,
748
748
  },
749
749
  toSessionSnapshot(session, this.context.capabilities),
750
750
  {
@@ -48,6 +48,7 @@ export interface BackendSessionState {
48
48
  export interface BackendContext {
49
49
  fileSystem?: BackendFileSystem;
50
50
  host?: BackendHostAdapter;
51
+ allowedProjectRoots?: readonly string[];
51
52
  capabilities: BackendCapabilities;
52
53
  sessions: Map<string, BackendSessionState>;
53
54
  sessionsByPath: Map<string, string>;
@@ -5,7 +5,7 @@ import {
5
5
  normalizeUamProject,
6
6
  type UamProject,
7
7
  } from '@openfairygui/core/uam';
8
- import { normalizeComparablePath, resolveCanonicalProjectRoot } from '../path-policy.js';
8
+ import { assertProjectPathContained, normalizeComparablePath, resolveCanonicalProjectRoot } from '../path-policy.js';
9
9
  import type {
10
10
  AdvisoryLockConflictError,
11
11
  BackendCapabilityUnavailableError,
@@ -14,6 +14,8 @@ import type {
14
14
  BackendSessionSnapshot,
15
15
  InProcessLockConflictError,
16
16
  OpenProjectSessionInput,
17
+ ProjectRootNotAllowedError,
18
+ SessionIdConflictError,
17
19
  SessionNotFoundError,
18
20
  } from '../runtime.js';
19
21
  import type { CacheService } from './cache-service.js';
@@ -23,7 +25,7 @@ import type { JobService } from './job-service.js';
23
25
  import { createSessionNotFoundError, toSessionSnapshot } from './session-utils.js';
24
26
 
25
27
  function randomId(): string {
26
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
28
+ return crypto.randomUUID();
27
29
  }
28
30
 
29
31
  function createCapabilityUnavailableError(
@@ -42,29 +44,36 @@ function createCapabilityUnavailableError(
42
44
  };
43
45
  }
44
46
 
45
- function createProjectReaderFileSystem(fileSystem: NonNullable<BackendContext['fileSystem']>): FileSystem {
47
+ function createProjectReaderFileSystem(
48
+ fileSystem: NonNullable<BackendContext['fileSystem']>,
49
+ projectRoot: string,
50
+ ): FileSystem {
51
+ const contained = async <T>(filePath: string, operation: () => Promise<T>): Promise<T> => {
52
+ await assertProjectPathContained(fileSystem, projectRoot, filePath);
53
+ return operation();
54
+ };
46
55
  return {
47
56
  readFile(filePath: string): Promise<string> {
48
- return fileSystem.readFile(filePath);
57
+ return contained(filePath, () => fileSystem.readFile(filePath));
49
58
  },
50
59
  readFileRaw(filePath: string): Promise<Uint8Array> {
51
- return fileSystem.readFileRaw(filePath);
60
+ return contained(filePath, () => fileSystem.readFileRaw(filePath));
52
61
  },
53
62
  writeFile(filePath: string, content: string): Promise<void> {
54
- return fileSystem.writeFile(filePath, content);
63
+ return contained(filePath, () => fileSystem.writeFile(filePath, content));
55
64
  },
56
65
  writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
57
- return fileSystem.writeFileRaw(filePath, data);
66
+ return contained(filePath, () => fileSystem.writeFileRaw(filePath, data));
58
67
  },
59
68
  async mkdir(dirPath: string): Promise<void> {
60
- await fileSystem.mkdir(dirPath, { recursive: true });
69
+ await contained(dirPath, () => fileSystem.mkdir(dirPath, { recursive: true }));
61
70
  },
62
71
  readdir(dirPath: string): Promise<string[]> {
63
- return fileSystem.readdir(dirPath);
72
+ return contained(dirPath, () => fileSystem.readdir(dirPath));
64
73
  },
65
74
  async exists(filePath: string): Promise<boolean> {
66
75
  try {
67
- await fileSystem.stat(filePath);
76
+ await contained(filePath, () => fileSystem.stat(filePath));
68
77
  return true;
69
78
  } catch {
70
79
  return false;
@@ -180,7 +189,7 @@ export class RuntimeService {
180
189
  }): Promise<
181
190
  BackendResult<
182
191
  BackendSessionSnapshot,
183
- InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError
192
+ InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError | ProjectRootNotAllowedError
184
193
  >
185
194
  > {
186
195
  const startedAt = Date.now();
@@ -188,10 +197,31 @@ export class RuntimeService {
188
197
  return failure('runtime', startedAt, createCapabilityUnavailableError('fileSystem'));
189
198
  }
190
199
  const fileSystem = this.context.fileSystem;
200
+ if (this.context.allowedProjectRoots?.length) {
201
+ let allowed = false;
202
+ for (const root of this.context.allowedProjectRoots) {
203
+ try {
204
+ await assertProjectPathContained(fileSystem, root, input.projectPath);
205
+ allowed = true;
206
+ break;
207
+ } catch (error) {
208
+ if ((error as { code?: unknown }).code !== 'EACCES') throw error;
209
+ }
210
+ }
211
+ if (!allowed) {
212
+ return failure('runtime', startedAt, {
213
+ code: 'project_root_not_allowed',
214
+ message: 'Project path is outside the configured allowed roots.',
215
+ projectPath: input.projectPath,
216
+ });
217
+ }
218
+ }
191
219
  const resolved = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
192
220
  const { fairyPath, canonicalProjectPath, canonicalPathKey } = resolved;
221
+ await fileSystem.validateProjectRoot?.(canonicalProjectPath);
193
222
  const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
194
- const lockFilePath = fileSystem.join(canonicalProjectPath, '.openfairygui.backend.lock');
223
+ const lockFilePath = fileSystem.getSessionLockPath?.(canonicalProjectPath)
224
+ ?? fileSystem.join(canonicalProjectPath, '.openfairygui.backend.lock');
195
225
 
196
226
  if (existingSessionId) {
197
227
  return failure('runtime', startedAt, {
@@ -219,7 +249,7 @@ export class RuntimeService {
219
249
  },
220
250
  ),
221
251
  );
222
- const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
252
+ const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem, canonicalProjectPath));
223
253
  const read = await reader.readDetailed(fairyPath, { hydrateResourceBytes: true });
224
254
  if (!read.document) throw new Error(read.diagnostics[0]?.message ?? `Unable to read project: ${fairyPath}`);
225
255
  const document = read.document;
@@ -276,9 +306,18 @@ export class RuntimeService {
276
306
  }
277
307
  }
278
308
 
279
- public openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot> {
309
+ public openProjectSession(
310
+ input: OpenProjectSessionInput,
311
+ ): BackendResult<BackendSessionSnapshot, InProcessLockConflictError | SessionIdConflictError> {
280
312
  const startedAt = Date.now();
281
313
  const sessionId = input.sessionId ?? randomId();
314
+ if (this.context.sessions.has(sessionId)) {
315
+ return failure('runtime', startedAt, {
316
+ code: 'session_id_conflict',
317
+ message: `Session id is already in use: ${sessionId}`,
318
+ sessionId,
319
+ });
320
+ }
282
321
  const storage = input.storage;
283
322
  const memoryProjectPath = `memory://${sessionId}`;
284
323
  const canonicalProjectPath =