@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/README.md +4 -2
- package/dist/index.cjs +89 -29
- package/dist/index.d.cts +30 -7
- package/dist/index.d.mts +30 -7
- package/dist/index.mjs +89 -29
- package/dist/node.cjs +277 -44
- package/dist/node.d.cts +29 -6
- package/dist/node.d.mts +29 -6
- package/dist/node.mjs +276 -44
- package/package.json +8 -5
- package/src/index.ts +3 -0
- package/src/node.ts +213 -14
- package/src/path-policy.ts +18 -0
- package/src/runtime/capabilities.ts +2 -2
- package/src/runtime/contracts.ts +35 -3
- package/src/runtime.ts +27 -8
- package/src/services/authoring-service.ts +3 -3
- package/src/services/context.ts +1 -0
- package/src/services/runtime-service.ts +53 -14
- package/src/services/session-project-writer.ts +31 -13
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
},
|
package/src/path-policy.ts
CHANGED
|
@@ -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
|
|
105
|
+
atomicSave,
|
|
106
106
|
staleRevisionProtection: true,
|
|
107
107
|
pathPolicy: createRuntimePathPolicy(),
|
|
108
108
|
events: {
|
package/src/runtime/contracts.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
110
|
+
InProcessLockConflictError
|
|
111
|
+
| AdvisoryLockConflictError
|
|
112
|
+
| BackendCapabilityUnavailableError
|
|
113
|
+
| ProjectRootNotAllowedError
|
|
114
|
+
| ProjectOpenFailedError
|
|
107
115
|
>
|
|
108
116
|
> {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
747
|
+
diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction,
|
|
748
748
|
},
|
|
749
749
|
toSessionSnapshot(session, this.context.capabilities),
|
|
750
750
|
{
|
package/src/services/context.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|
|
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(
|
|
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 =
|