The production composition hardwired the macOS Keychain (/usr/bin/security), which made Linux deployment impossible. Now: - SecretStore gains a stable provider identity persisted in secret_references.provider; services stop hardcoding 'macos-keychain' - shared reference codec (secret-reference.ts) understands both keychain:// and secret-file:// references - FileSecretStore: single 0600 JSON map under the data root, atomic temp+rename writes, serialized in-process, same validation envelope - production composition picks the backend via MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store elsewhere) and readiness probes the matching backend
620 lines
24 KiB
TypeScript
620 lines
24 KiB
TypeScript
import type Database from 'better-sqlite3';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type {
|
|
CapabilityStatus,
|
|
Instance,
|
|
InstanceInput,
|
|
InstancePage,
|
|
InstancePageQuery,
|
|
InstancePatch,
|
|
SnapshotFreshness,
|
|
} from '@multi-simadmin/contracts';
|
|
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
|
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
|
|
|
const PURPOSE = 'instance-password';
|
|
const MAX_TAGS = 50;
|
|
const MAX_TAG_LENGTH = 100;
|
|
|
|
export type InstanceServiceErrorCode =
|
|
| 'VALIDATION_FAILED'
|
|
| 'NOT_FOUND'
|
|
| 'REVISION_CONFLICT'
|
|
| 'HAS_JOB_HISTORY'
|
|
| 'DUPLICATE_ID'
|
|
| 'DUPLICATE_ORIGIN'
|
|
| 'SECRET_STORE_FAILED'
|
|
| 'DATABASE_FAILED'
|
|
| 'SECRET_CLEANUP_FAILED'
|
|
| 'COMPENSATION_FAILED'
|
|
| 'COMPENSATION_PERSISTENCE_FAILED';
|
|
|
|
export class InstanceServiceError extends Error {
|
|
constructor(
|
|
readonly code: InstanceServiceErrorCode,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'InstanceServiceError';
|
|
}
|
|
}
|
|
|
|
export interface InstanceServiceOptions {
|
|
readonly db: Database.Database;
|
|
readonly store: SecretStore;
|
|
readonly idFactory?: () => string;
|
|
readonly now?: () => Date;
|
|
}
|
|
|
|
interface InstanceRow {
|
|
id: string;
|
|
name: string;
|
|
base_url: string;
|
|
config_revision: number;
|
|
updated_at: string;
|
|
group_id: string | null;
|
|
}
|
|
interface SecretRow {
|
|
id: string;
|
|
external_reference: string;
|
|
}
|
|
interface CleanupEntry {
|
|
readonly instanceId: string;
|
|
readonly reference: string;
|
|
readonly purpose: string;
|
|
readonly provider: string;
|
|
readonly generation: number;
|
|
readonly queuedAt: string;
|
|
}
|
|
interface AggregateRow extends InstanceRow {
|
|
tags: string | null;
|
|
credential: number;
|
|
}
|
|
|
|
function validation(message: string): never {
|
|
throw new InstanceServiceError('VALIDATION_FAILED', message);
|
|
}
|
|
function normalizeName(value: unknown): string {
|
|
if (typeof value !== 'string') return validation('Name must be a string');
|
|
const result = value.trim();
|
|
if (!result || result.length > 200) return validation('Name must contain 1 to 200 characters');
|
|
return result;
|
|
}
|
|
function normalizeTags(value: readonly string[] | undefined): string[] {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value) || value.length > MAX_TAGS) return validation('Tags are invalid');
|
|
const tags = value.map((tag) => {
|
|
if (typeof tag !== 'string') return validation('Tag must be a string');
|
|
const normalized = tag.trim();
|
|
if (!normalized || normalized.length > MAX_TAG_LENGTH || /[\x00-\x1F\x7F]/.test(normalized))
|
|
return validation('Tag is invalid');
|
|
return normalized;
|
|
});
|
|
const unique = [...new Set(tags)].sort(codePointCompare);
|
|
if (unique.length > MAX_TAGS) return validation('Too many tags');
|
|
return unique;
|
|
}
|
|
function privateIpv4(host: string): boolean {
|
|
const parts = host.split('.');
|
|
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false;
|
|
const octets = parts.map(Number);
|
|
if (octets.some((part) => part > 255)) return false;
|
|
const [a, b] = octets as [number, number, number, number];
|
|
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
}
|
|
function privateIpv6(host: string): boolean {
|
|
const normalized = host.toLowerCase().replace(/^\[|\]$/g, '');
|
|
return /^(fc|fd)[0-9a-f]{2}:/.test(normalized);
|
|
}
|
|
function normalizeOrigin(value: unknown): string {
|
|
if (typeof value !== 'string') return validation('Origin must be a URL');
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
return validation('Origin must be a valid URL');
|
|
}
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
return validation('Origin must use HTTP or HTTPS');
|
|
if (url.username || url.password || url.search || url.hash)
|
|
return validation('Origin must not contain credentials, query, or fragment');
|
|
if (url.pathname !== '/' && url.pathname !== '')
|
|
return validation('Origin must not contain a path');
|
|
if (!privateIpv4(url.hostname) && !privateIpv6(url.hostname))
|
|
return validation('Origin must target a private LAN address');
|
|
return url.origin;
|
|
}
|
|
function validatePassword(value: InstanceInput['password'] | InstancePatch['password']): void {
|
|
if (value === undefined || value.action === 'preserve' || value.action === 'clear') return;
|
|
if (value.action !== 'set' || typeof value.password !== 'string' || value.password.length === 0)
|
|
validation('Password update is invalid');
|
|
}
|
|
|
|
const capabilityRank: Record<CapabilityStatus, number> = {
|
|
degraded: 0,
|
|
'auth-required': 1,
|
|
unsupported: 2,
|
|
supported: 3,
|
|
unknown: 4,
|
|
};
|
|
const freshnessRank: Record<SnapshotFreshness, number> = {
|
|
expired: 0,
|
|
stale: 1,
|
|
fresh: 2,
|
|
unknown: 3,
|
|
};
|
|
|
|
export class InstanceService {
|
|
private readonly db: Database.Database;
|
|
private readonly store: SecretStore;
|
|
private readonly id: () => string;
|
|
private readonly clock: () => Date;
|
|
|
|
constructor(options: InstanceServiceOptions) {
|
|
this.db = options.db;
|
|
this.store = options.store;
|
|
this.id = options.idFactory ?? randomUUID;
|
|
this.clock = options.now ?? (() => new Date());
|
|
}
|
|
|
|
async create(input: InstanceInput): Promise<Instance> {
|
|
const name = normalizeName(input.name);
|
|
const origin = normalizeOrigin(input.origin);
|
|
const tags = normalizeTags(input.tags);
|
|
const groupId = this.normalizeGroupId(input.groupId);
|
|
validatePassword(input.password);
|
|
const instanceId = this.id();
|
|
let newSecret: { id: string; external: string } | undefined;
|
|
if (input.password?.action === 'set')
|
|
newSecret = await this.storeSecret(instanceId, input.password.password);
|
|
const now = this.clock().toISOString();
|
|
try {
|
|
this.db.transaction(() => {
|
|
this.db
|
|
.prepare(
|
|
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at,group_id) VALUES (?,?,?, ?,1,1,?,?,?)',
|
|
)
|
|
.run(instanceId, name, origin, newSecret ? 'password' : 'none', now, now, groupId);
|
|
this.replaceTags(instanceId, tags, now);
|
|
if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now);
|
|
})();
|
|
} catch (error) {
|
|
if (newSecret) await this.compensate(instanceId, newSecret.external);
|
|
if (isUnique(error, 'base_url'))
|
|
throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists');
|
|
if (isUnique(error, 'instances.id'))
|
|
throw new InstanceServiceError('DUPLICATE_ID', 'Instance id already exists');
|
|
throw new InstanceServiceError('DATABASE_FAILED', 'Could not create instance');
|
|
}
|
|
return (await this.get(instanceId))!;
|
|
}
|
|
|
|
async get(instanceId: string): Promise<Instance | undefined> {
|
|
const rows = this.loadRows('WHERE i.id = ?', [instanceId]);
|
|
return rows[0] ? this.toInstance(rows[0]) : undefined;
|
|
}
|
|
|
|
async list(query: InstancePageQuery = {}): Promise<InstancePage> {
|
|
const pageSize = integerInRange(query.pageSize ?? 25, 1, 100, 'pageSize');
|
|
const page = integerInRange(
|
|
query.page ?? 1,
|
|
1,
|
|
Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1,
|
|
'page',
|
|
);
|
|
if (query.direction !== undefined && query.direction !== 'asc' && query.direction !== 'desc')
|
|
validation('Direction is invalid');
|
|
const validSort = ['name', 'status', 'freshness', 'updatedAt'];
|
|
if (query.sort !== undefined && !validSort.includes(query.sort)) validation('Sort is invalid');
|
|
let values = this.loadRows('', []).map((row) => ({
|
|
value: this.toInstance(row),
|
|
updatedAt: row.updated_at,
|
|
}));
|
|
const search = query.search?.trim().toLowerCase();
|
|
if (search)
|
|
values = values.filter(
|
|
({ value }) =>
|
|
value.name.toLowerCase().includes(search) || value.id.toLowerCase().includes(search),
|
|
);
|
|
if (query.tag !== undefined)
|
|
values = values.filter(({ value }) => value.tags.includes(query.tag!.trim()));
|
|
if (query.groupId !== undefined)
|
|
values = values.filter(({ value }) => value.groupId === query.groupId!.trim());
|
|
if (query.credentialConfigured !== undefined)
|
|
values = values.filter(
|
|
({ value }) => value.credentialConfigured === query.credentialConfigured,
|
|
);
|
|
if (query.capabilityStatus !== undefined)
|
|
values = values.filter(({ value }) => value.capabilityStatus === query.capabilityStatus);
|
|
if (query.freshness !== undefined)
|
|
values = values.filter(({ value }) => value.freshness === query.freshness);
|
|
const direction = query.direction === 'desc' ? -1 : 1;
|
|
const sort = query.sort ?? 'name';
|
|
values.sort((left, right) => {
|
|
let result: number;
|
|
if (sort === 'status')
|
|
result =
|
|
capabilityRank[left.value.capabilityStatus]! -
|
|
capabilityRank[right.value.capabilityStatus]!;
|
|
else if (sort === 'freshness')
|
|
result = freshnessRank[left.value.freshness]! - freshnessRank[right.value.freshness]!;
|
|
else if (sort === 'updatedAt') result = codePointCompare(left.updatedAt, right.updatedAt);
|
|
else result = codePointCompare(left.value.name, right.value.name);
|
|
return result === 0 ? codePointCompare(left.value.id, right.value.id) : result * direction;
|
|
});
|
|
const total = values.length;
|
|
const start = (page - 1) * pageSize;
|
|
return {
|
|
items: values.slice(start, start + pageSize).map(({ value }) => value),
|
|
page: { page, pageSize, total },
|
|
};
|
|
}
|
|
|
|
async update(instanceId: string, revision: number, patch: InstancePatch): Promise<Instance> {
|
|
integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision');
|
|
validatePassword(patch.password);
|
|
const current = this.row(instanceId);
|
|
if (!current) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
|
if (current.config_revision !== revision)
|
|
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
|
const name = patch.name === undefined ? current.name : normalizeName(patch.name);
|
|
const origin = patch.origin === undefined ? current.base_url : normalizeOrigin(patch.origin);
|
|
const tags = patch.tags === undefined ? undefined : normalizeTags(patch.tags);
|
|
const groupId =
|
|
patch.groupId === undefined ? current.group_id : this.normalizeGroupId(patch.groupId);
|
|
let committedOldSecret: SecretRow | undefined;
|
|
let newSecret: { id: string; external: string } | undefined;
|
|
if (patch.password?.action === 'set')
|
|
newSecret = await this.storeSecret(instanceId, patch.password.password);
|
|
const now = this.clock().toISOString();
|
|
try {
|
|
this.db.transaction(() => {
|
|
const transactionCurrent = this.row(instanceId);
|
|
if (!transactionCurrent)
|
|
throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
|
if (transactionCurrent.config_revision !== revision)
|
|
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
|
const transactionOldSecret = this.secret(instanceId);
|
|
const changed = this.db
|
|
.prepare(
|
|
'UPDATE instances SET name=?,base_url=?,auth_mode=?,config_revision=config_revision+1,updated_at=?,group_id=? WHERE id=? AND config_revision=?',
|
|
)
|
|
.run(
|
|
name,
|
|
origin,
|
|
newSecret || (transactionOldSecret && patch.password?.action !== 'clear')
|
|
? 'password'
|
|
: 'none',
|
|
now,
|
|
groupId,
|
|
instanceId,
|
|
revision,
|
|
);
|
|
if (changed.changes !== 1)
|
|
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
|
if (tags) this.replaceTags(instanceId, tags, now);
|
|
if (patch.password?.action === 'clear' || newSecret)
|
|
this.db
|
|
.prepare('DELETE FROM secret_references WHERE instance_id=? AND purpose=?')
|
|
.run(instanceId, PURPOSE);
|
|
if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now);
|
|
if (transactionOldSecret && (patch.password?.action === 'clear' || newSecret)) {
|
|
this.queueCleanup(instanceId, transactionOldSecret.external_reference, now);
|
|
committedOldSecret = transactionOldSecret;
|
|
}
|
|
})();
|
|
} catch (error) {
|
|
if (newSecret) await this.compensate(instanceId, newSecret.external);
|
|
if (error instanceof InstanceServiceError) throw error;
|
|
if (isUnique(error, 'base_url'))
|
|
throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists');
|
|
throw new InstanceServiceError('DATABASE_FAILED', 'Could not update instance');
|
|
}
|
|
if (committedOldSecret)
|
|
await this.tryCleanup(instanceId, committedOldSecret.external_reference);
|
|
return (await this.get(instanceId))!;
|
|
}
|
|
|
|
async delete(
|
|
instanceId: string,
|
|
revision: number,
|
|
options?: { allowJobHistory?: boolean },
|
|
): Promise<void> {
|
|
integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision');
|
|
const allowJobHistory = options?.allowJobHistory === true;
|
|
let committedOldSecret: SecretRow | undefined;
|
|
try {
|
|
this.db.transaction(() => {
|
|
const transactionCurrent = this.row(instanceId);
|
|
if (!transactionCurrent)
|
|
throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
|
if (transactionCurrent.config_revision !== revision)
|
|
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
|
if (
|
|
!allowJobHistory &&
|
|
this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId)
|
|
)
|
|
throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history');
|
|
const transactionOldSecret = this.secret(instanceId);
|
|
if (transactionOldSecret) {
|
|
this.queueCleanup(
|
|
instanceId,
|
|
transactionOldSecret.external_reference,
|
|
this.clock().toISOString(),
|
|
);
|
|
committedOldSecret = transactionOldSecret;
|
|
}
|
|
const deleted = this.db
|
|
.prepare('DELETE FROM instances WHERE id=? AND config_revision=?')
|
|
.run(instanceId, revision);
|
|
if (deleted.changes !== 1)
|
|
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
|
})();
|
|
} catch (error) {
|
|
if (error instanceof InstanceServiceError) throw error;
|
|
if (
|
|
!allowJobHistory &&
|
|
this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId)
|
|
)
|
|
throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history');
|
|
throw new InstanceServiceError('DATABASE_FAILED', 'Could not delete instance');
|
|
}
|
|
if (committedOldSecret)
|
|
await this.tryCleanup(instanceId, committedOldSecret.external_reference);
|
|
}
|
|
|
|
async retryPendingSecretCleanup(): Promise<{
|
|
attempted: number;
|
|
cleaned: number;
|
|
remaining: number;
|
|
}> {
|
|
const entries = this.cleanupEntries();
|
|
let cleaned = 0;
|
|
for (const entry of entries) {
|
|
try {
|
|
this.validateCleanupEntry(entry);
|
|
if (this.isActiveReference(entry.reference)) continue;
|
|
await this.store.delete(entry.reference);
|
|
if (this.removeCleanup(entry.reference, entry.generation)) cleaned += 1;
|
|
} catch {
|
|
// Retain the opaque reference for a later explicit retry.
|
|
}
|
|
}
|
|
return { attempted: entries.length, cleaned, remaining: this.cleanupEntries().length };
|
|
}
|
|
|
|
private row(id: string): InstanceRow | undefined {
|
|
return this.db
|
|
.prepare('SELECT id,name,base_url,config_revision,updated_at FROM instances WHERE id=?')
|
|
.get(id) as InstanceRow | undefined;
|
|
}
|
|
private secret(id: string): SecretRow | undefined {
|
|
return this.db
|
|
.prepare(
|
|
'SELECT id,external_reference FROM secret_references WHERE instance_id=? AND purpose=?',
|
|
)
|
|
.get(id, PURPOSE) as SecretRow | undefined;
|
|
}
|
|
private replaceTags(id: string, tags: readonly string[], now: string): void {
|
|
this.db.prepare('DELETE FROM instance_tags WHERE instance_id=?').run(id);
|
|
const insert = this.db.prepare(
|
|
'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)',
|
|
);
|
|
for (const tag of tags) insert.run(id, tag, now);
|
|
}
|
|
private insertReference(id: string, instanceId: string, external: string, now: string): void {
|
|
this.db
|
|
.prepare(
|
|
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
|
)
|
|
.run(id, instanceId, PURPOSE, this.store.provider, external, now, now);
|
|
}
|
|
private async storeSecret(
|
|
instanceId: string,
|
|
password: string,
|
|
): Promise<{ id: string; external: string }> {
|
|
const slot = this.id();
|
|
let external: string;
|
|
try {
|
|
external = await this.store.set({ instanceId, purpose: PURPOSE, slot }, password);
|
|
} catch {
|
|
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
|
}
|
|
try {
|
|
const parsed = parseSecretReference(external);
|
|
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
|
|
throw new Error('secret store returned a reference with an invalid binding');
|
|
} catch {
|
|
try {
|
|
await this.store.delete(external);
|
|
} catch {
|
|
throw new InstanceServiceError(
|
|
'COMPENSATION_PERSISTENCE_FAILED',
|
|
'Credential binding was invalid and cleanup could not be confirmed',
|
|
);
|
|
}
|
|
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
|
}
|
|
return { id: this.id(), external };
|
|
}
|
|
private async compensate(instanceId: string, reference: string): Promise<void> {
|
|
try {
|
|
this.validateCleanupReference(instanceId, reference);
|
|
await this.store.delete(reference);
|
|
} catch {
|
|
try {
|
|
this.queueCleanup(instanceId, reference, this.clock().toISOString());
|
|
} catch {
|
|
throw new InstanceServiceError(
|
|
'COMPENSATION_PERSISTENCE_FAILED',
|
|
'Database operation failed and credential cleanup could not be persisted',
|
|
);
|
|
}
|
|
throw new InstanceServiceError(
|
|
'COMPENSATION_FAILED',
|
|
'Database operation failed and new credential cleanup failed',
|
|
);
|
|
}
|
|
}
|
|
private async tryCleanup(instanceId: string, reference: string): Promise<void> {
|
|
const generation = this.cleanupGeneration(reference);
|
|
try {
|
|
this.validateCleanupReference(instanceId, reference);
|
|
await this.store.delete(reference);
|
|
if (generation !== undefined) this.removeCleanup(reference, generation);
|
|
} catch {
|
|
// The durable outbox is authoritative after the business transaction commits.
|
|
}
|
|
}
|
|
private cleanupEntries(): CleanupEntry[] {
|
|
return this.db
|
|
.prepare(
|
|
'SELECT reference,instance_id,purpose,provider,generation,queued_at FROM secret_cleanup_tasks ORDER BY queued_at ASC,reference ASC',
|
|
)
|
|
.all()
|
|
.map((row) => {
|
|
const task = row as {
|
|
reference: string;
|
|
instance_id: string;
|
|
purpose: string;
|
|
provider: string;
|
|
generation: number;
|
|
queued_at: string;
|
|
};
|
|
return {
|
|
reference: task.reference,
|
|
instanceId: task.instance_id,
|
|
purpose: task.purpose,
|
|
provider: task.provider,
|
|
generation: task.generation,
|
|
queuedAt: task.queued_at,
|
|
};
|
|
});
|
|
}
|
|
private queueCleanup(instanceId: string, reference: string, now: string): void {
|
|
this.validateCleanupReference(instanceId, reference);
|
|
const queued = this.db
|
|
.prepare(
|
|
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at) VALUES (?,?,?,?,1,?,?)
|
|
ON CONFLICT(reference) DO UPDATE SET
|
|
generation=secret_cleanup_tasks.generation+1,
|
|
updated_at=excluded.updated_at
|
|
WHERE secret_cleanup_tasks.instance_id=excluded.instance_id
|
|
AND secret_cleanup_tasks.purpose=excluded.purpose
|
|
AND secret_cleanup_tasks.provider=excluded.provider`,
|
|
)
|
|
.run(reference, instanceId, PURPOSE, this.store.provider, now, now);
|
|
if (queued.changes !== 1)
|
|
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
|
|
}
|
|
private cleanupGeneration(reference: string): number | undefined {
|
|
const row = this.db
|
|
.prepare('SELECT generation FROM secret_cleanup_tasks WHERE reference=?')
|
|
.get(reference) as { generation: number } | undefined;
|
|
return row?.generation;
|
|
}
|
|
private removeCleanup(reference: string, generation: number): boolean {
|
|
return (
|
|
this.db
|
|
.prepare('DELETE FROM secret_cleanup_tasks WHERE reference=? AND generation=?')
|
|
.run(reference, generation).changes === 1
|
|
);
|
|
}
|
|
private isActiveReference(reference: string): boolean {
|
|
return Boolean(
|
|
this.db
|
|
.prepare('SELECT 1 FROM secret_references WHERE external_reference=? LIMIT 1')
|
|
.get(reference),
|
|
);
|
|
}
|
|
private validateCleanupEntry(entry: CleanupEntry): void {
|
|
if (entry.purpose !== PURPOSE || entry.provider !== this.store.provider)
|
|
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
|
this.validateCleanupReference(entry.instanceId, entry.reference);
|
|
}
|
|
private validateCleanupReference(instanceId: string, reference: string): void {
|
|
try {
|
|
const parsed = parseSecretReference(reference);
|
|
if (
|
|
parsed.instanceId !== instanceId ||
|
|
parsed.purpose !== PURPOSE ||
|
|
parsed.slot === undefined
|
|
)
|
|
throw new Error('mismatch');
|
|
} catch {
|
|
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
|
}
|
|
}
|
|
private loadRows(where: string, parameters: unknown[]): AggregateRow[] {
|
|
return this.db
|
|
.prepare(
|
|
`SELECT i.id,i.name,i.base_url,i.config_revision,i.updated_at,i.group_id,
|
|
group_concat(t.tag, char(31)) tags,
|
|
CASE WHEN EXISTS(SELECT 1 FROM secret_references r WHERE r.instance_id=i.id AND r.purpose=?) THEN 1 ELSE 0 END credential
|
|
FROM instances i LEFT JOIN instance_tags t ON t.instance_id=i.id ${where} GROUP BY i.id`,
|
|
)
|
|
.all(PURPOSE, ...parameters) as AggregateRow[];
|
|
}
|
|
|
|
private normalizeGroupId(value: string | null | undefined): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value !== 'string') validation('groupId must be a string or null');
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const exists = this.db.prepare('SELECT 1 FROM device_groups WHERE id=?').get(trimmed);
|
|
if (!exists) throw new InstanceServiceError('NOT_FOUND', 'Device group was not found');
|
|
return trimmed;
|
|
}
|
|
private toInstance(row: AggregateRow): Instance {
|
|
const capabilityRows = this.db
|
|
.prepare('SELECT state FROM capabilities WHERE instance_id=?')
|
|
.all(row.id) as { state: CapabilityStatus }[];
|
|
const capabilityStatus =
|
|
capabilityRows.length === 0
|
|
? 'unknown'
|
|
: capabilityRows
|
|
.map((x) => x.state)
|
|
.sort((a, b) => capabilityRank[a]! - capabilityRank[b]!)[0]!;
|
|
const snapshots = this.db
|
|
.prepare('SELECT state,expires_at FROM status_snapshots WHERE instance_id=?')
|
|
.all(row.id) as { state: SnapshotFreshness; expires_at: string | null }[];
|
|
const freshness: SnapshotFreshness =
|
|
snapshots.length === 0
|
|
? 'unknown'
|
|
: snapshots
|
|
.map((snapshot) =>
|
|
snapshot.expires_at && snapshot.expires_at <= this.clock().toISOString()
|
|
? 'expired'
|
|
: snapshot.state,
|
|
)
|
|
.sort((left, right) => freshnessRank[left]! - freshnessRank[right]!)[0]!;
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
origin: row.base_url,
|
|
tags: row.tags ? row.tags.split(String.fromCharCode(31)).sort(codePointCompare) : [],
|
|
groupId: row.group_id ?? null,
|
|
revision: row.config_revision,
|
|
capabilityStatus,
|
|
freshness,
|
|
credentialConfigured: row.credential === 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
function integerInRange(value: number, minimum: number, maximum: number, name: string): number {
|
|
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
|
|
validation(`${name} is invalid`);
|
|
return value;
|
|
}
|
|
function codePointCompare(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
function isUnique(error: unknown, fragment: string): boolean {
|
|
return (
|
|
error instanceof Error &&
|
|
error.message.includes('UNIQUE constraint failed') &&
|
|
error.message.includes(fragment)
|
|
);
|
|
}
|