Add packages/mosaic registry schemas, validation, read-only CLI; pin pi 0.85.1 (#1499)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// Registry tree loading and cross-record reference validation.
|
||||
// Reads a supplied registry root; never writes, never touches credentials.
|
||||
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { validateProvider, validateAccount, validateSettingsProfile, validateSeatSelection, validateHarnessManifest } from "./records.mjs";
|
||||
|
||||
export async function loadRegistry(root) {
|
||||
const entries = { providers: {}, accounts: {}, profiles: {}, selections: {}, harnesses: {} };
|
||||
const errors = [];
|
||||
|
||||
const providersDir = join(root, "auth", "providers");
|
||||
for (const file of await safeList(providersDir, errors)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const id = file.slice(0, -5);
|
||||
const record = await readJson(join(providersDir, file), errors);
|
||||
if (record === undefined) continue;
|
||||
errors.push(...validateProvider(record));
|
||||
if (record.id !== id) errors.push(new (await import("./records.mjs")).ValidationError(`providers/${file}`, "id-path-mismatch", `${record.id} vs ${id}`));
|
||||
if (entries.providers[id]) errors.push(new (await import("./records.mjs")).ValidationError(`providers/${file}`, "duplicate-id", id));
|
||||
entries.providers[id] = record;
|
||||
}
|
||||
|
||||
const accountsDir = join(root, "auth", "accounts");
|
||||
for (const provider of await safeList(accountsDir, errors)) {
|
||||
for (const accountDir of await safeList(join(accountsDir, provider), errors)) {
|
||||
// account.json sits in a per-account directory; credential.json is never read.
|
||||
const record = await readJson(join(accountsDir, provider, accountDir, "account.json"), errors);
|
||||
if (record === undefined) continue;
|
||||
errors.push(...validateAccount(record, provider));
|
||||
const accountRef = `${provider}/${accountDir}`;
|
||||
if (record.id !== accountDir) errors.push(new (await import("./records.mjs")).ValidationError(`accounts/${provider}/${accountDir}`, "id-path-mismatch", `${record.id} vs ${accountDir}`));
|
||||
if (entries.accounts[accountRef]) errors.push(new (await import("./records.mjs")).ValidationError(`accounts/${provider}/${accountDir}`, "duplicate-id", accountRef));
|
||||
entries.accounts[accountRef] = record;
|
||||
}
|
||||
}
|
||||
|
||||
const settingsDir = join(root, "auth", "settings");
|
||||
for (const file of await safeList(settingsDir, errors)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const record = await readJson(join(settingsDir, file), errors);
|
||||
if (record === undefined) continue;
|
||||
errors.push(...validateSettingsProfile(record));
|
||||
if (record.id !== file.slice(0, -5)) errors.push(new (await import("./records.mjs")).ValidationError(`settings/${file}`, "id-path-mismatch"));
|
||||
entries.profiles[file.slice(0, -5)] = record;
|
||||
}
|
||||
|
||||
const harnessesDir = join(root, "harnesses");
|
||||
for (const file of await safeList(harnessesDir, errors)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const record = await readJson(join(harnessesDir, file), errors);
|
||||
if (record === undefined) continue;
|
||||
errors.push(...validateHarnessManifest(record));
|
||||
entries.harnesses[file.slice(0, -5)] = record;
|
||||
}
|
||||
|
||||
// Cross-record reference integrity.
|
||||
for (const [ref, account] of Object.entries(entries.accounts)) {
|
||||
if (!entries.providers[account.provider]) {
|
||||
errors.push(new (await import("./records.mjs")).ValidationError(`accounts/${ref}`, "missing-provider", account.provider));
|
||||
}
|
||||
}
|
||||
for (const [pid, profile] of Object.entries(entries.profiles)) {
|
||||
for (const ref of profile.allowedAccounts ?? []) {
|
||||
if (!entries.accounts[ref]) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "missing-account", ref));
|
||||
}
|
||||
for (const provider of profile.providers ?? []) {
|
||||
if (!entries.providers[provider]) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "missing-provider", provider));
|
||||
}
|
||||
for (const [provider, ref] of Object.entries(profile.defaultAccounts ?? {})) {
|
||||
if (!entries.accounts[ref]) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "missing-default-account", ref));
|
||||
else if (!ref.startsWith(`${provider}/`)) errors.push(new (await import("./records.mjs")).ValidationError(`profiles/${pid}`, "default-account-provider-mismatch", ref));
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, errors };
|
||||
}
|
||||
|
||||
async function safeList(dir, errors) {
|
||||
try {
|
||||
const s = await stat(dir);
|
||||
if (!s.isDirectory()) throw new Error("not-a-directory");
|
||||
return await readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(path, errors) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
} catch (err) {
|
||||
errors.push(new ValidationErrorCompat(path, "invalid-json", err.message));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class ValidationErrorCompat extends Error {
|
||||
constructor(path, code, detail) {
|
||||
super(`${path}: ${code}: ${detail}`);
|
||||
this.name = "ValidationError";
|
||||
this.path = path;
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user