dsh-im-ops/src/channels/office/config-store.mjs

107 lines
3.7 KiB
JavaScript

import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import { normalizeOfficeBaseUrl, OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
const ALIAS = /^[a-z][a-z0-9-]{1,63}$/;
const DEVICE_ID = /^[a-z0-9][a-z0-9_-]{2,63}$/i;
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizeMap(value, { kind }) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const output = {};
for (const [rawId, rawValue] of Object.entries(value)) {
const id = cleanString(rawId);
const item = cleanString(rawValue);
if (!id || !ALIAS.test(id) || !item) return null;
if (kind === 'workspace' && !isAbsolute(item)) return null;
if (kind === 'preset' && item.length > 8_000) return null;
output[id] = item;
}
return Object.freeze(output);
}
export function normalizeOfficeConfig(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
let baseUrl;
try {
baseUrl = normalizeOfficeBaseUrl(value.baseUrl).origin;
} catch {
return null;
}
const deviceId = cleanString(value.deviceId);
const deviceTokenRef = cleanString(value.deviceTokenRef);
const workspaces = normalizeMap(value.workspaces ?? {}, { kind: 'workspace' });
const instructionPresets = normalizeMap(value.instructionPresets ?? {}, { kind: 'preset' });
const maxConcurrency = Number(value.maxConcurrency ?? 1);
const heartbeatSeconds = Number(value.heartbeatSeconds ?? 30);
if (!deviceId || !DEVICE_ID.test(deviceId) || !deviceTokenRef
|| !/^DSH_OFFICE_DEVICE_TOKEN_[A-F0-9]{24}$/.test(deviceTokenRef)
|| !workspaces || !instructionPresets
|| !Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4
|| !Number.isInteger(heartbeatSeconds) || heartbeatSeconds < 10 || heartbeatSeconds > 300) {
return null;
}
return Object.freeze({
version: 1,
protocolVersion: OFFICE_PROTOCOL_VERSION,
baseUrl,
deviceId,
deviceTokenRef,
maxConcurrency,
heartbeatSeconds,
workspaces,
instructionPresets,
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
updatedAt: cleanString(value.updatedAt) ?? new Date().toISOString(),
});
}
export class OfficeConfigStore {
#path;
#value = null;
#queue = Promise.resolve();
constructor(path) { this.#path = path; }
async load() {
try {
const normalized = normalizeOfficeConfig(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im AI Office config is invalid');
this.#value = normalized;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#value = null;
}
return this;
}
get() { return this.#value ? structuredClone(this.#value) : null; }
async save(value) {
const normalized = normalizeOfficeConfig(value);
if (!normalized) throw new Error('Refusing to persist invalid AI Office configuration');
const operation = this.#queue.then(async () => {
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, this.#path);
this.#value = normalized;
});
this.#queue = operation.then(() => undefined, () => undefined);
await operation;
return this.get();
}
async clear() {
const operation = this.#queue.then(async () => {
try { await unlink(this.#path); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
this.#value = null;
});
this.#queue = operation.then(() => undefined, () => undefined);
await operation;
}
}