@lazyingart/agent-web 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/docs/architecture.md +503 -0
  4. package/package.json +43 -0
  5. package/src/aginti-adapter.js +602 -0
  6. package/src/chat-context.js +1020 -0
  7. package/src/chat-migrations.js +947 -0
  8. package/src/chat-store.js +3308 -0
  9. package/src/cli.js +134 -0
  10. package/src/cloud-server.js +2043 -0
  11. package/src/contracts.js +103 -0
  12. package/src/deterministic-context-summarizer.js +254 -0
  13. package/src/direct-chat-capability-limits.js +66 -0
  14. package/src/direct-chat-contract.js +3 -0
  15. package/src/errors.js +50 -0
  16. package/src/http-contract.js +592 -0
  17. package/src/index.js +88 -0
  18. package/src/localllm-connector.js +667 -0
  19. package/src/migrations.js +231 -0
  20. package/src/operator-health.js +184 -0
  21. package/src/password-verifier.js +131 -0
  22. package/src/service-config.js +547 -0
  23. package/src/service.js +408 -0
  24. package/src/sqlite-health.js +83 -0
  25. package/src/storage-path.js +130 -0
  26. package/src/store.js +914 -0
  27. package/src/validation.js +181 -0
  28. package/src/vision-attachment.js +404 -0
  29. package/src/web/aginti-client.js +552 -0
  30. package/src/web/aginti-protocol.js +1146 -0
  31. package/src/web/asset-map.js +462 -0
  32. package/src/web/browser-app.js +6491 -0
  33. package/src/web/cloud-session-client.js +427 -0
  34. package/src/web/direct-chat-client.js +1482 -0
  35. package/src/web/index.js +10 -0
  36. package/src/web/presentation-state.js +107 -0
  37. package/src/web/pwa-assets.js +854 -0
  38. package/src/web/pwa-update-handoff-store.js +179 -0
  39. package/src/web/safe-rendering.js +836 -0
  40. package/src/web/vision-image-client.js +546 -0
  41. package/src/web/vision-image-sanitizer.js +168 -0
  42. package/src/web/web-release.js +28 -0
package/src/service.js ADDED
@@ -0,0 +1,408 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import {
4
+ createAgintiAgentAdapter,
5
+ validateAgintiTransportCredential
6
+ } from './aginti-adapter.js';
7
+ import { DirectChatContextCoordinator } from './chat-context.js';
8
+ import {
9
+ CHAT_SQLITE_APPLICATION_ID,
10
+ DEFAULT_CHAT_SCHEMA_VERSION,
11
+ LATEST_CHAT_SCHEMA_VERSION
12
+ } from './chat-migrations.js';
13
+ import { DirectChatStore } from './chat-store.js';
14
+ import { createCloudServer } from './cloud-server.js';
15
+ import { createDeterministicContextSummarizer } from './deterministic-context-summarizer.js';
16
+ import { createLocalLlmConnector } from './localllm-connector.js';
17
+ import { LATEST_SCHEMA_VERSION, SQLITE_APPLICATION_ID } from './migrations.js';
18
+ import {
19
+ OPERATOR_HEALTH_TIMEOUT_MS,
20
+ createOperatorHealthReport
21
+ } from './operator-health.js';
22
+ import {
23
+ createScryptPasswordVerifier,
24
+ validateScryptPasswordHash
25
+ } from './password-verifier.js';
26
+ import { assertLoadedServiceConfig } from './service-config.js';
27
+ import { checkSqliteFileHealth } from './sqlite-health.js';
28
+ import { CloudIndexStore } from './store.js';
29
+ import { AGINTI_RPC_PATHS } from './web/aginti-protocol.js';
30
+ import {
31
+ createStandaloneAssetMap,
32
+ verifyStandaloneAssetMap
33
+ } from './web/asset-map.js';
34
+
35
+ export const TRUSTED_STANDALONE_BOOTSTRAP_SOURCE = `
36
+ import katex from "./katex.mjs";
37
+ import { createBrowserApp } from "./browser-app.js";
38
+ import { createSafeRenderer } from "./safe-rendering.js";
39
+
40
+ const renderer = createSafeRenderer({ katex });
41
+ const app = createBrowserApp({ renderer });
42
+ void app.initialize();
43
+ `;
44
+
45
+ function sha256(value) {
46
+ return createHash('sha256').update(value, 'utf8').digest('hex');
47
+ }
48
+
49
+ const OPERATOR_HEALTH_BROWSER_SESSION = sha256('lazying-agent-web/operator-health/v1');
50
+
51
+ function validateTransportCredential(value, label) {
52
+ if (typeof value !== 'string' || value.length < 16 || value.length > 4_096
53
+ || /[\s\u0000-\u001f\u007f]/u.test(value)) {
54
+ throw new TypeError(`${label} transport credential is invalid`);
55
+ }
56
+ return true;
57
+ }
58
+
59
+ async function buildAssetMap(config) {
60
+ const assetMap = await createStandaloneAssetMap({
61
+ bootstrapSource: TRUSTED_STANDALONE_BOOTSTRAP_SOURCE,
62
+ versionLabel: config.pwa.versionLabel,
63
+ basePath: '/',
64
+ title: config.pwa.title,
65
+ loginPath: '/api/login',
66
+ name: config.pwa.name,
67
+ shortName: config.pwa.shortName
68
+ });
69
+ verifyStandaloneAssetMap(assetMap);
70
+ if (assetMap.serviceWorkerRoute !== '/sw.js') {
71
+ throw new TypeError('standalone PWA service worker must remain at stable /sw.js');
72
+ }
73
+ return assetMap;
74
+ }
75
+
76
+ async function materializeInputs(loadedConfig, { createVerifier = true } = {}) {
77
+ const loaded = assertLoadedServiceConfig(loadedConfig);
78
+ let passwordHash = loaded.readCredential('passwordHash');
79
+ let passwordVerifier;
80
+ try {
81
+ if (createVerifier) passwordVerifier = createScryptPasswordVerifier(passwordHash);
82
+ else validateScryptPasswordHash(passwordHash);
83
+ } finally {
84
+ passwordHash = null;
85
+ }
86
+ let localLlmCredential = loaded.readCredential('localLlmToken');
87
+ try {
88
+ validateTransportCredential(localLlmCredential, 'LocalLLM');
89
+ } finally {
90
+ localLlmCredential = null;
91
+ }
92
+ if (loaded.config.aginti.enabled) {
93
+ let agintiCredential = loaded.readCredential('agintiToken');
94
+ try {
95
+ validateAgintiTransportCredential(agintiCredential);
96
+ } finally {
97
+ agintiCredential = null;
98
+ }
99
+ }
100
+ const assetMap = await buildAssetMap(loaded.config);
101
+ return Object.freeze({
102
+ loaded,
103
+ passwordVerifier,
104
+ assetMap,
105
+ localLlmCredentialProvider: loaded.createCredentialProvider('localLlmToken'),
106
+ ...(loaded.config.aginti.enabled
107
+ ? { agintiCredentialProvider: loaded.createCredentialProvider('agintiToken') }
108
+ : {})
109
+ });
110
+ }
111
+
112
+ function accountProvisionKey(config) {
113
+ return `service-account-v1.${sha256(JSON.stringify({
114
+ principalId: config.account.principalId,
115
+ username: config.account.username,
116
+ displayName: config.account.displayName
117
+ }))}`;
118
+ }
119
+
120
+ function closeStores(controlStore, directChatStore) {
121
+ const failures = [];
122
+ for (const store of [directChatStore, controlStore]) {
123
+ if (!store) continue;
124
+ try {
125
+ store.close();
126
+ } catch (error) {
127
+ failures.push(error);
128
+ }
129
+ }
130
+ if (failures.length === 1) throw failures[0];
131
+ if (failures.length > 1) throw new AggregateError(failures, 'service stores did not close cleanly');
132
+ }
133
+
134
+ function safeReport(config, assetMap) {
135
+ return Object.freeze({
136
+ valid: true,
137
+ schema: config.schema,
138
+ listen: Object.freeze({ ...config.listen }),
139
+ publicOrigin: config.publicOrigin,
140
+ releaseId: assetMap.releaseVersion,
141
+ serviceWorkerRoute: assetMap.serviceWorkerRoute,
142
+ agentEnabled: false,
143
+ agentConfigured: config.aginti.enabled,
144
+ directChatEnabled: true
145
+ });
146
+ }
147
+
148
+ function safeEdgeRouteManifest(config, assetMap) {
149
+ verifyStandaloneAssetMap(assetMap);
150
+ const requestTargets = Object.freeze([...assetMap.routes]);
151
+ const paths = Object.freeze([...new Set(requestTargets.map((target) => target.split('?', 1)[0]))].sort());
152
+ if (paths.some((pathname) => typeof pathname !== 'string' || !pathname.startsWith('/'))
153
+ || requestTargets.some((target) => typeof target !== 'string' || !target.startsWith('/'))) {
154
+ throw new TypeError('standalone PWA edge route manifest is invalid');
155
+ }
156
+ return Object.freeze({
157
+ schema: 'lazying-agent-web/edge-route-manifest/v1',
158
+ publicOrigin: config.publicOrigin,
159
+ releaseId: assetMap.releaseVersion,
160
+ methods: Object.freeze(['GET', 'HEAD']),
161
+ paths,
162
+ requestTargets
163
+ });
164
+ }
165
+
166
+ export async function checkStandaloneServiceConfiguration(loadedConfig) {
167
+ const materialized = await materializeInputs(loadedConfig, { createVerifier: false });
168
+ return safeReport(materialized.loaded.config, materialized.assetMap);
169
+ }
170
+
171
+ export async function createStandaloneServiceEdgeRouteManifest(loadedConfig) {
172
+ const materialized = await materializeInputs(loadedConfig, { createVerifier: false });
173
+ return safeEdgeRouteManifest(materialized.loaded.config, materialized.assetMap);
174
+ }
175
+
176
+ export async function checkStandaloneServiceHealth(loadedConfig, {
177
+ fetchImpl,
178
+ dependencyTimeoutMs = OPERATOR_HEALTH_TIMEOUT_MS,
179
+ databaseHealthChecker = checkSqliteFileHealth,
180
+ clock
181
+ } = {}) {
182
+ if (fetchImpl !== undefined && typeof fetchImpl !== 'function') {
183
+ throw new TypeError('fetchImpl must be a function');
184
+ }
185
+ if (typeof databaseHealthChecker !== 'function') {
186
+ throw new TypeError('databaseHealthChecker must be a function');
187
+ }
188
+ if (clock !== undefined && typeof clock !== 'function') throw new TypeError('clock must be a function');
189
+
190
+ const loaded = assertLoadedServiceConfig(loadedConfig);
191
+ const config = loaded.config;
192
+ const assetMap = await buildAssetMap(config);
193
+ const localLlmConnector = createLocalLlmConnector({
194
+ baseUrl: config.localLlm.baseUrl,
195
+ allowedModelAliases: config.localLlm.allowedModelAliases,
196
+ credentialProvider: loaded.createCredentialProvider('localLlmToken'),
197
+ ...(fetchImpl === undefined ? {} : { fetchImpl })
198
+ });
199
+ const agintiAdapter = config.aginti.enabled
200
+ ? createAgintiAgentAdapter({
201
+ upstream: config.aginti.baseUrl,
202
+ credentialProvider: loaded.createCredentialProvider('agintiToken'),
203
+ ...(fetchImpl === undefined ? {} : { fetchImpl })
204
+ })
205
+ : null;
206
+ const allowedDirectChatSchemas = config.localLlm.vision.enabled
207
+ ? [LATEST_CHAT_SCHEMA_VERSION]
208
+ : [...new Set([DEFAULT_CHAT_SCHEMA_VERSION, LATEST_CHAT_SCHEMA_VERSION])];
209
+
210
+ return createOperatorHealthReport({
211
+ releaseId: assetMap.releaseVersion,
212
+ cloudIndexProbe: () => databaseHealthChecker({
213
+ databasePath: config.state.cloudIndexDatabase,
214
+ expectedApplicationId: SQLITE_APPLICATION_ID,
215
+ allowedSchemaVersions: [LATEST_SCHEMA_VERSION]
216
+ }),
217
+ directChatProbe: () => databaseHealthChecker({
218
+ databasePath: config.state.directChatDatabase,
219
+ expectedApplicationId: CHAT_SQLITE_APPLICATION_ID,
220
+ allowedSchemaVersions: allowedDirectChatSchemas
221
+ }),
222
+ localLlmProbe: ({ signal }) => localLlmConnector.readiness({ signal }),
223
+ agintiProbe: agintiAdapter === null
224
+ ? null
225
+ : ({ signal }) => agintiAdapter.rpc(AGINTI_RPC_PATHS.capabilities, {}, {
226
+ principalId: config.account.principalId,
227
+ browserSession: OPERATOR_HEALTH_BROWSER_SESSION,
228
+ signal
229
+ }),
230
+ dependencyTimeoutMs,
231
+ ...(clock === undefined ? {} : { clock })
232
+ });
233
+ }
234
+
235
+ export async function createStandaloneService({
236
+ loadedConfig,
237
+ fetchImpl,
238
+ localSummarizer,
239
+ serverFactory = createCloudServer,
240
+ clock
241
+ } = {}) {
242
+ if (typeof serverFactory !== 'function') throw new TypeError('serverFactory must be a function');
243
+ if (fetchImpl !== undefined && typeof fetchImpl !== 'function') {
244
+ throw new TypeError('fetchImpl must be a function');
245
+ }
246
+ if (localSummarizer !== undefined && localSummarizer !== null
247
+ && (typeof localSummarizer !== 'object' || localSummarizer.locality !== 'local'
248
+ || typeof localSummarizer.summarizeDirectChat !== 'function')) {
249
+ throw new TypeError('localSummarizer must be a local-only Direct Chat summarizer');
250
+ }
251
+ if (clock !== undefined && typeof clock !== 'function') throw new TypeError('clock must be a function');
252
+
253
+ const materialized = await materializeInputs(loadedConfig);
254
+ const config = materialized.loaded.config;
255
+ let controlStore;
256
+ let directChatStore;
257
+ let server;
258
+ try {
259
+ controlStore = new CloudIndexStore({
260
+ databasePath: config.state.cloudIndexDatabase,
261
+ ...(clock === undefined ? {} : { clock })
262
+ });
263
+ directChatStore = new DirectChatStore({
264
+ databasePath: config.state.directChatDatabase,
265
+ modelAlias: config.localLlm.defaultModelAlias,
266
+ visionModelAlias: config.localLlm.vision.modelAlias,
267
+ enableVisionAttachments: config.localLlm.vision.enabled,
268
+ ...(clock === undefined ? {} : { clock })
269
+ });
270
+ const account = controlStore.provisionAccount({
271
+ accountId: config.account.principalId,
272
+ issuer: 'local-login',
273
+ subject: config.account.username,
274
+ displayName: config.account.displayName,
275
+ idempotencyKey: accountProvisionKey(config)
276
+ });
277
+ if (account.issuer !== 'local-login' || account.subject !== config.account.username
278
+ || account.displayName !== config.account.displayName) {
279
+ throw new TypeError('provisioned account does not match the immutable service identity config');
280
+ }
281
+ const directChatSummarizer = localSummarizer ?? createDeterministicContextSummarizer();
282
+ const directChatContext = new DirectChatContextCoordinator({
283
+ store: directChatStore,
284
+ localSummarizer: directChatSummarizer
285
+ });
286
+ const directChatConnector = createLocalLlmConnector({
287
+ baseUrl: config.localLlm.baseUrl,
288
+ allowedModelAliases: config.localLlm.allowedModelAliases,
289
+ credentialProvider: materialized.localLlmCredentialProvider,
290
+ ...(fetchImpl === undefined ? {} : { fetchImpl })
291
+ });
292
+ const agintiAdapter = config.aginti.enabled
293
+ ? createAgintiAgentAdapter({
294
+ upstream: config.aginti.baseUrl,
295
+ credentialProvider: materialized.agintiCredentialProvider,
296
+ ...(fetchImpl === undefined ? {} : { fetchImpl })
297
+ })
298
+ : null;
299
+ server = serverFactory({
300
+ releaseId: materialized.assetMap.releaseVersion,
301
+ assetMap: materialized.assetMap,
302
+ publicOrigin: config.publicOrigin,
303
+ account: {
304
+ username: config.account.username,
305
+ principalId: config.account.principalId
306
+ },
307
+ passwordVerifier: materialized.passwordVerifier,
308
+ sessionStore: controlStore,
309
+ controlStore,
310
+ directChatStore,
311
+ directChatContext,
312
+ directChatSummarizer,
313
+ directChatConnector,
314
+ visionEnabled: config.localLlm.vision.enabled,
315
+ visionModelAlias: config.localLlm.vision.modelAlias,
316
+ agintiAdapter,
317
+ requestOutcomeObserver(outcome) {
318
+ if (outcome.result === 'rejected') {
319
+ console.warn(JSON.stringify({ event: 'cloud_request_outcome', ...outcome }));
320
+ }
321
+ },
322
+ ...(clock === undefined ? {} : { clock })
323
+ });
324
+ if (!server || typeof server.listen !== 'function' || typeof server.shutdown !== 'function'
325
+ || typeof server.address !== 'function' || typeof server.once !== 'function'
326
+ || typeof server.removeListener !== 'function') {
327
+ throw new TypeError('serverFactory returned an invalid graceful server');
328
+ }
329
+
330
+ let startPromise = null;
331
+ let shutdownPromise = null;
332
+ let stopped = false;
333
+ const start = () => {
334
+ if (stopped) return Promise.reject(new Error('standalone service is already stopped'));
335
+ if (startPromise) return startPromise;
336
+ startPromise = new Promise((resolve, reject) => {
337
+ const onError = (error) => {
338
+ server.removeListener('error', onError);
339
+ reject(error);
340
+ };
341
+ server.once('error', onError);
342
+ server.listen(config.listen.port, '127.0.0.1', () => {
343
+ server.removeListener('error', onError);
344
+ const address = server.address();
345
+ if (!address || typeof address !== 'object' || address.address !== '127.0.0.1'
346
+ || address.port !== config.listen.port) {
347
+ void shutdown();
348
+ reject(new Error('server did not bind the exact configured loopback endpoint'));
349
+ return;
350
+ }
351
+ resolve(Object.freeze({ address: '127.0.0.1', port: address.port }));
352
+ });
353
+ });
354
+ return startPromise;
355
+ };
356
+ const shutdown = () => {
357
+ if (shutdownPromise) return shutdownPromise;
358
+ stopped = true;
359
+ shutdownPromise = (async () => {
360
+ const failures = [];
361
+ try {
362
+ await server.shutdown();
363
+ } catch (error) {
364
+ failures.push(error);
365
+ }
366
+ try {
367
+ closeStores(controlStore, directChatStore);
368
+ } catch (error) {
369
+ failures.push(error);
370
+ }
371
+ if (failures.length === 1) throw failures[0];
372
+ if (failures.length > 1) throw new AggregateError(failures, 'standalone service shutdown failed');
373
+ })();
374
+ return shutdownPromise;
375
+ };
376
+
377
+ return Object.freeze({
378
+ kind: 'lazying-agent-standalone-service',
379
+ releaseId: materialized.assetMap.releaseVersion,
380
+ listen: Object.freeze({ ...config.listen }),
381
+ publicOrigin: config.publicOrigin,
382
+ account: Object.freeze({ ...account }),
383
+ agentEnabled: false,
384
+ assetMap: materialized.assetMap,
385
+ server,
386
+ controlStore,
387
+ directChatStore,
388
+ directChatContext,
389
+ directChatSummarizer,
390
+ directChatConnector,
391
+ agintiAdapter,
392
+ start,
393
+ shutdown
394
+ });
395
+ } catch (error) {
396
+ try {
397
+ if (server?.shutdown) await server.shutdown();
398
+ } catch {
399
+ // Preserve the construction failure.
400
+ }
401
+ try {
402
+ closeStores(controlStore, directChatStore);
403
+ } catch {
404
+ // Preserve the construction failure.
405
+ }
406
+ throw error;
407
+ }
408
+ }
@@ -0,0 +1,83 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+
4
+ import { StorageCorruptionError, ValidationError } from './errors.js';
5
+ import { assertSecureDatabaseFile } from './storage-path.js';
6
+
7
+ const SQLITE_VERSION_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}$/u;
8
+
9
+ function normalizedSchemaVersions(value) {
10
+ if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
11
+ throw new ValidationError('allowedSchemaVersions must be a short non-empty array.');
12
+ }
13
+ const versions = [...new Set(value)];
14
+ if (versions.length !== value.length
15
+ || versions.some((version) => !Number.isSafeInteger(version) || version < 1)) {
16
+ throw new ValidationError('allowedSchemaVersions contains an invalid schema version.');
17
+ }
18
+ return Object.freeze(versions);
19
+ }
20
+
21
+ export function checkOpenSqliteHealth(database, {
22
+ expectedApplicationId,
23
+ allowedSchemaVersions
24
+ } = {}) {
25
+ if (!database || typeof database.prepare !== 'function') {
26
+ throw new ValidationError('database must provide prepare().');
27
+ }
28
+ if (!Number.isSafeInteger(expectedApplicationId) || expectedApplicationId < 1) {
29
+ throw new ValidationError('expectedApplicationId is invalid.');
30
+ }
31
+ const schemaVersions = normalizedSchemaVersions(allowedSchemaVersions);
32
+
33
+ const quickCheck = database.prepare('PRAGMA quick_check').get();
34
+ if (quickCheck?.quick_check !== 'ok') throw new StorageCorruptionError();
35
+ if (database.prepare('PRAGMA foreign_key_check').all().length !== 0) {
36
+ throw new StorageCorruptionError('The database contains invalid ownership references.');
37
+ }
38
+ const schemaVersion = Number(database.prepare('PRAGMA user_version').get()?.user_version);
39
+ const applicationId = Number(database.prepare('PRAGMA application_id').get()?.application_id);
40
+ if (!schemaVersions.includes(schemaVersion) || applicationId !== expectedApplicationId) {
41
+ throw new StorageCorruptionError('The database identity or schema version changed unexpectedly.');
42
+ }
43
+ const sqliteVersion = database.prepare('SELECT sqlite_version() AS version').get()?.version;
44
+ if (typeof sqliteVersion !== 'string' || !SQLITE_VERSION_PATTERN.test(sqliteVersion)) {
45
+ throw new StorageCorruptionError('SQLite returned an invalid runtime version.');
46
+ }
47
+ return Object.freeze({ ready: true, schemaVersion, sqliteVersion });
48
+ }
49
+
50
+ export function checkSqliteFileHealth({
51
+ databasePath,
52
+ expectedApplicationId,
53
+ allowedSchemaVersions
54
+ } = {}) {
55
+ if (typeof databasePath !== 'string' || !isAbsolute(databasePath)
56
+ || resolve(databasePath) !== databasePath) {
57
+ throw new ValidationError('databasePath must be an absolute normalized path.');
58
+ }
59
+ assertSecureDatabaseFile(databasePath);
60
+ let database;
61
+ try {
62
+ database = new DatabaseSync(databasePath, { readOnly: true });
63
+ database.enableLoadExtension(false);
64
+ database.exec(`
65
+ PRAGMA busy_timeout = 1000;
66
+ PRAGMA query_only = ON;
67
+ PRAGMA trusted_schema = OFF;
68
+ `);
69
+ return checkOpenSqliteHealth(database, {
70
+ expectedApplicationId,
71
+ allowedSchemaVersions
72
+ });
73
+ } catch (error) {
74
+ if (error instanceof StorageCorruptionError || error instanceof ValidationError) throw error;
75
+ throw new StorageCorruptionError('The database could not be inspected read-only.', { cause: error });
76
+ } finally {
77
+ try {
78
+ database?.close();
79
+ } finally {
80
+ assertSecureDatabaseFile(databasePath);
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ constants,
5
+ lstatSync,
6
+ mkdirSync,
7
+ openSync,
8
+ realpathSync
9
+ } from 'node:fs';
10
+ import { dirname, isAbsolute, parse, resolve, sep } from 'node:path';
11
+
12
+ import { StorageSecurityError, ValidationError } from './errors.js';
13
+
14
+ function currentUid() {
15
+ return typeof process.getuid === 'function' ? process.getuid() : null;
16
+ }
17
+
18
+ function assertOwned(stat, label) {
19
+ const uid = currentUid();
20
+ if (uid !== null && stat.uid !== uid) {
21
+ throw new StorageSecurityError(`${label} must be owned by the current user.`);
22
+ }
23
+ }
24
+
25
+ function assertPrivateMode(stat, label) {
26
+ if ((stat.mode & 0o077) !== 0) {
27
+ throw new StorageSecurityError(`${label} must not be accessible by group or other users.`);
28
+ }
29
+ }
30
+
31
+ function lstatIfPresent(pathname) {
32
+ try {
33
+ return lstatSync(pathname);
34
+ } catch (error) {
35
+ if (error?.code === 'ENOENT') return null;
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function pathComponents(absolutePath) {
41
+ const { root } = parse(absolutePath);
42
+ const relative = absolutePath.slice(root.length);
43
+ const parts = relative.length === 0 ? [] : relative.split(sep).filter(Boolean);
44
+ const paths = [root];
45
+ let current = root;
46
+ for (const part of parts) {
47
+ current = resolve(current, part);
48
+ paths.push(current);
49
+ }
50
+ return paths;
51
+ }
52
+
53
+ function assertNoSymlinkComponents(absolutePath) {
54
+ for (const candidate of pathComponents(absolutePath)) {
55
+ const stat = lstatIfPresent(candidate);
56
+ if (!stat) break;
57
+ if (stat.isSymbolicLink()) {
58
+ throw new StorageSecurityError(`Storage path component ${JSON.stringify(candidate)} must not be a symbolic link.`);
59
+ }
60
+ }
61
+ }
62
+
63
+ export function prepareSecureDatabasePath(databasePath) {
64
+ if (typeof databasePath !== 'string' || databasePath.length === 0 || !isAbsolute(databasePath)) {
65
+ throw new ValidationError('databasePath must be a non-empty absolute filesystem path.');
66
+ }
67
+ const resolvedPath = resolve(databasePath);
68
+ const stateDirectory = dirname(resolvedPath);
69
+ if (stateDirectory === resolvedPath) {
70
+ throw new StorageSecurityError('databasePath must name a file inside a private state directory.');
71
+ }
72
+
73
+ assertNoSymlinkComponents(stateDirectory);
74
+ mkdirSync(stateDirectory, { recursive: true, mode: 0o700 });
75
+ assertNoSymlinkComponents(stateDirectory);
76
+
77
+ const directoryStat = lstatSync(stateDirectory);
78
+ if (!directoryStat.isDirectory()) {
79
+ throw new StorageSecurityError('The database parent must be a directory.');
80
+ }
81
+ assertOwned(directoryStat, 'The database state directory');
82
+ assertPrivateMode(directoryStat, 'The database state directory');
83
+
84
+ if (realpathSync(stateDirectory) !== stateDirectory) {
85
+ throw new StorageSecurityError('The database state directory must resolve without indirection.');
86
+ }
87
+
88
+ const noFollow = constants.O_NOFOLLOW ?? 0;
89
+ const existingDatabase = lstatIfPresent(resolvedPath);
90
+ if (!existingDatabase) {
91
+ try {
92
+ const descriptor = openSync(
93
+ resolvedPath,
94
+ constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | noFollow,
95
+ 0o600
96
+ );
97
+ closeSync(descriptor);
98
+ chmodSync(resolvedPath, 0o600);
99
+ } catch (error) {
100
+ throw new StorageSecurityError('The database file could not be created without following links.', { cause: error });
101
+ }
102
+ } else {
103
+ assertSecureDatabaseFile(resolvedPath);
104
+ try {
105
+ const descriptor = openSync(resolvedPath, constants.O_RDWR | noFollow);
106
+ closeSync(descriptor);
107
+ } catch (error) {
108
+ throw new StorageSecurityError('The database file could not be opened without following links.', { cause: error });
109
+ }
110
+ }
111
+
112
+ assertSecureDatabaseFile(resolvedPath);
113
+ return resolvedPath;
114
+ }
115
+
116
+ export function assertSecureDatabaseFile(databasePath) {
117
+ assertNoSymlinkComponents(databasePath);
118
+ const stat = lstatSync(databasePath);
119
+ if (!stat.isFile() || stat.isSymbolicLink()) {
120
+ throw new StorageSecurityError('The database path must be a regular file, not a link or device.');
121
+ }
122
+ if (stat.nlink !== 1) {
123
+ throw new StorageSecurityError('The database file must not have additional hard links.');
124
+ }
125
+ assertOwned(stat, 'The database file');
126
+ assertPrivateMode(stat, 'The database file');
127
+ if (realpathSync(databasePath) !== databasePath) {
128
+ throw new StorageSecurityError('The database file must resolve without indirection.');
129
+ }
130
+ }