118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
import type Database from 'better-sqlite3';
|
|
import { stat as fsStat } from 'node:fs/promises';
|
|
import { MIGRATIONS } from './infrastructure/database/migrations.js';
|
|
import type { ReadinessResult } from './app.js';
|
|
|
|
export interface ProductionReadinessOptions {
|
|
readonly db: Database.Database;
|
|
readonly gatewayToken: string;
|
|
/** Metadata/availability probe only. This callback must never retrieve a secret. */
|
|
readonly keychainMetadataCheck: () => boolean | Promise<boolean>;
|
|
/** Release gates are deliberately required: omitting one fails readiness closed. */
|
|
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
|
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
|
readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>;
|
|
}
|
|
interface ExecutableMetadata {
|
|
isFile(): boolean;
|
|
readonly mode: number;
|
|
}
|
|
export async function defaultKeychainMetadataCheck(
|
|
platform: NodeJS.Platform = process.platform,
|
|
stat: (path: string) => Promise<ExecutableMetadata> = fsStat,
|
|
): Promise<boolean> {
|
|
if (platform !== 'darwin') return false;
|
|
try {
|
|
const metadata = await stat('/usr/bin/security');
|
|
return metadata.isFile() && (metadata.mode & 0o111) !== 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
export function createProductionReadiness(
|
|
options: ProductionReadinessOptions,
|
|
): () => Promise<ReadinessResult> {
|
|
return async () => {
|
|
const checks: Record<string, string> = {};
|
|
let ready = true;
|
|
try {
|
|
options.db.prepare('SELECT 1').get();
|
|
checks.database = 'ready';
|
|
} catch {
|
|
checks.database = 'unavailable';
|
|
ready = false;
|
|
}
|
|
try {
|
|
const expected = MIGRATIONS.at(-1)?.id ?? 0;
|
|
const row = options.db.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as {
|
|
id: number | null;
|
|
};
|
|
checks.migrations = row.id === expected ? 'ready' : 'pending';
|
|
if (row.id !== expected) ready = false;
|
|
} catch {
|
|
checks.migrations = 'unavailable';
|
|
ready = false;
|
|
}
|
|
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
|
|
if (checks.gatewayAuth !== 'ready') ready = false;
|
|
try {
|
|
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
|
} catch {
|
|
checks.keychain = 'unavailable';
|
|
}
|
|
if (checks.keychain !== 'ready') ready = false;
|
|
|
|
try {
|
|
const row = options.db
|
|
.prepare(
|
|
"SELECT value_json FROM app_settings WHERE key = 'legacyImport.pendingCredentials'",
|
|
)
|
|
.get() as { value_json: string } | undefined;
|
|
const value: unknown = row === undefined ? 0 : JSON.parse(row.value_json);
|
|
checks.pendingCredentials =
|
|
Number.isSafeInteger(value) && (value as number) >= 0
|
|
? value === 0
|
|
? 'ready'
|
|
: `pending:${String(value)}`
|
|
: 'unavailable';
|
|
} catch {
|
|
checks.pendingCredentials = 'unavailable';
|
|
}
|
|
if (checks.pendingCredentials !== 'ready') ready = false;
|
|
|
|
if (options.recoveryMarkersCheck === undefined) {
|
|
checks.recoveryMarkers = 'unconfigured';
|
|
} else {
|
|
try {
|
|
const count = await options.recoveryMarkersCheck();
|
|
checks.recoveryMarkers =
|
|
Number.isSafeInteger(count) && count >= 0
|
|
? count === 0
|
|
? 'ready'
|
|
: `present:${String(count)}`
|
|
: 'unavailable';
|
|
} catch {
|
|
checks.recoveryMarkers = 'unavailable';
|
|
}
|
|
}
|
|
if (checks.recoveryMarkers !== 'ready') ready = false;
|
|
|
|
for (const [name, check] of [
|
|
['assets', options.assetsCheck],
|
|
['upstreamGateway', options.upstreamGatewayCheck],
|
|
] as const) {
|
|
if (check === undefined) {
|
|
checks[name] = 'unconfigured';
|
|
} else {
|
|
try {
|
|
checks[name] = (await check()) ? 'ready' : 'unavailable';
|
|
} catch {
|
|
checks[name] = 'unavailable';
|
|
}
|
|
}
|
|
if (checks[name] !== 'ready') ready = false;
|
|
}
|
|
return { ready, checks };
|
|
};
|
|
}
|