Files
healer-man/scripts/coa-reference/supplements.mjs
T

558 lines
24 KiB
JavaScript

import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
import { fileURLToPath } from "node:url";
export const COA_ARCHIVE = Object.freeze({
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
builderSnapshotSha256: "41a6ccd393c0648ae2b1fdc34fe94955d4fd13c0cd59040ad3860e64a8ff6c7a",
});
const defaultProjectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const expectedClassCount = 21;
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function assertSafeRelativePath(value, context = "archive path") {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${context} must be a non-empty string`);
}
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
throw new Error(`${context} must be relative: ${value}`);
}
if (normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`${context} contains an unsafe path segment: ${value}`);
}
return normalized;
}
function classFileFromDescriptor(descriptor) {
let candidate = null;
if (typeof descriptor === "string") {
candidate = descriptor;
} else if (descriptor && typeof descriptor === "object") {
candidate = descriptor.path ?? descriptor.file ?? descriptor.filename ?? descriptor.href ?? null;
if (candidate === null) candidate = descriptor.slug ?? descriptor.key ?? descriptor.id ?? null;
}
if (candidate === null || candidate === undefined) {
throw new Error("CoA talent class index contains an entry without a file path or class key");
}
const relative = assertSafeRelativePath(String(candidate), "CoA talent class file reference");
let filePath;
if (relative.startsWith("data/talents/coa/classes/")) {
filePath = relative;
} else if (relative.startsWith("classes/")) {
filePath = `data/talents/coa/${relative}`;
} else if (relative.startsWith("data/talents/coa/") && !relative.slice("data/talents/coa/".length).includes("/")) {
filePath = relative;
} else if (relative.includes("/")) {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
} else {
filePath = `data/talents/coa/${relative.endsWith(".json") ? relative : `${relative}.json`}`;
}
if (!filePath.startsWith("data/talents/coa/") || !filePath.endsWith(".json")
|| filePath === "data/talents/coa/index.json"
|| filePath === "data/talents/coa/_meta.json") {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
}
return filePath;
}
function isClassArchivePath(filePath) {
return filePath.startsWith("data/talents/coa/")
&& filePath.endsWith(".json")
&& filePath !== "data/talents/coa/index.json"
&& filePath !== "data/talents/coa/_meta.json";
}
function descriptorsFromIndex(index) {
const entries = index?.classes;
if (Array.isArray(entries)) return entries;
if (entries && typeof entries === "object") {
return Object.entries(entries).map(([key, value]) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") return { key, ...value };
return { key };
});
}
throw new Error("CoA talent index has no classes array or object");
}
function classPathsFromIndex(index) {
const descriptors = descriptorsFromIndex(index);
if (descriptors.length !== expectedClassCount) {
throw new Error(`CoA talent index expected ${expectedClassCount} class files, found ${descriptors.length}`);
}
const paths = descriptors.map(classFileFromDescriptor);
if (new Set(paths).size !== expectedClassCount) {
throw new Error("CoA talent index contains duplicate class file references");
}
return paths;
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, filePath) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error(`CoA archive JSON is invalid at ${filePath}: ${error.message}`, { cause: error });
}
}
function isRegularBlob(entry) {
return entry?.type === "blob" && entry?.mode === "100644" && /^[0-9a-f]{40}$/i.test(entry.sha ?? "");
}
export function createSupplementCacheApi(archiveInput = COA_ARCHIVE) {
const archive = Object.freeze({ ...archiveInput });
for (const key of ["repository", "revision", "shortRevision", "capturedAt", "fetchManifestPath", "builderSnapshotPath", "builderSnapshotSha256"]) {
if (typeof archive[key] !== "string" || archive[key].length === 0) {
throw new TypeError(`archive.${key} must be a non-empty string`);
}
}
if (!/^[0-9a-f]{40}$/i.test(archive.revision) || !/^[0-9a-f]{7,40}$/i.test(archive.shortRevision)) {
throw new TypeError("archive revision values must be hexadecimal Git revisions");
}
if (!/^[0-9a-f]{64}$/i.test(archive.builderSnapshotSha256)) {
throw new TypeError("archive.builderSnapshotSha256 must be a SHA-256 hex digest");
}
const requiredPaths = [
archive.fetchManifestPath,
archive.builderSnapshotPath,
"data/talents/coa/index.json",
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
];
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) {
throw new TypeError("cacheRoot must be a non-empty directory path");
}
return path.join(path.resolve(cacheRoot), `archive-${archive.shortRevision}`);
}
function archiveUrls(filePath) {
const apiUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
const rawPath = filePath.split("/").map(encodeURIComponent).join("/");
const rawUrl = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/${rawPath}`;
return { apiUrl, rawUrl };
}
function parseCaptureManifest(bytes) {
const capture = parseJson(bytes, archive.fetchManifestPath);
if (capture?.capturedUtc !== archive.capturedAt) {
throw new Error(`CoA capture manifest has an unexpected capture time: ${String(capture?.capturedUtc)}`);
}
if (capture?.savedAs !== path.posix.basename(archive.builderSnapshotPath)) {
throw new Error(`CoA capture manifest has an unexpected savedAs value: ${String(capture?.savedAs)}`);
}
if (capture?.sha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA capture manifest does not pin builder SHA-256 ${archive.builderSnapshotSha256}`);
}
return capture;
}
function sourceRecord() {
return {
repository: `https://github.com/${archive.repository}`,
revision: archive.revision,
capturedAt: archive.capturedAt,
captureManifestPath: archive.fetchManifestPath,
builderSnapshotPath: archive.builderSnapshotPath,
builderSnapshotSha256: archive.builderSnapshotSha256,
};
}
function validateManifestShape(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("CoA supplement manifest is not an object");
}
if (manifest.schemaVersion !== cacheSchemaVersion) {
throw new Error(`Unsupported CoA supplement cache schema: ${String(manifest.schemaVersion)}`);
}
if (manifest.source?.revision !== archive.revision) {
throw new Error("CoA supplement manifest points at an unexpected archive revision");
}
if (typeof manifest.generation !== "string" || !/^generation-[0-9a-f-]+$/.test(manifest.generation)) {
throw new Error("CoA supplement manifest has an invalid generation name");
}
if (!Array.isArray(manifest.files)) throw new Error("CoA supplement manifest has no file list");
const source = manifest.source;
if (source.captureManifestPath !== archive.fetchManifestPath
|| source.builderSnapshotPath !== archive.builderSnapshotPath
|| source.builderSnapshotSha256 !== archive.builderSnapshotSha256
|| source.capturedAt !== archive.capturedAt) {
throw new Error("CoA supplement manifest has unexpected capture metadata");
}
}
function expectedFilesFromManifest(manifest) {
validateManifestShape(manifest);
const expected = new Map();
for (const file of manifest.files) {
const filePath = assertSafeRelativePath(file?.path, "CoA supplement manifest file path");
if (expected.has(filePath)) throw new Error(`CoA supplement manifest repeats ${filePath}`);
if (!/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")) {
throw new Error(`CoA supplement manifest has an invalid Git blob SHA for ${filePath}`);
}
if (!/^[0-9a-f]{64}$/i.test(file.sha256 ?? "")) {
throw new Error(`CoA supplement manifest has an invalid SHA-256 for ${filePath}`);
}
if (!Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error(`CoA supplement manifest has an invalid size for ${filePath}`);
}
expected.set(filePath, file);
}
for (const filePath of requiredPaths) {
if (!expected.has(filePath)) throw new Error(`CoA supplement manifest is missing ${filePath}`);
}
const classPaths = [...expected.keys()].filter(isClassArchivePath);
if (classPaths.length !== expectedClassCount || expected.size !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplement manifest must contain ${expectedClassCount} class files and ${requiredPaths.length} required files`);
}
return expected;
}
async function responseBytes(fetchImpl, url, accept) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: accept } });
} catch (error) {
throw new Error(`CoA supplemental source unavailable at ${url}: ${error.message}`, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : `HTTP ${response.status}`;
throw new Error(`CoA supplemental source unavailable at ${url} (${status})`);
}
try {
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
} catch (error) {
throw new Error(`CoA supplemental source read failed at ${url}: ${error.message}`, { cause: error });
}
throw new Error(`CoA supplemental source response has no readable body at ${url}`);
}
async function fetchTree(fetchImpl) {
const { apiUrl } = archiveUrls(archive.fetchManifestPath);
const bytes = await responseBytes(fetchImpl, apiUrl, "application/vnd.github+json");
const treeDocument = parseJson(bytes, "GitHub archive tree");
if (treeDocument.truncated === true) {
throw new Error("CoA archive tree was truncated; refusing an incomplete supplemental cache");
}
if (!Array.isArray(treeDocument.tree)) throw new Error("CoA archive tree response does not contain a tree array");
return new Map(treeDocument.tree.map((entry) => [entry.path, entry]));
}
function expectedFile(tree, filePath) {
const safePath = assertSafeRelativePath(filePath);
const entry = tree.get(safePath);
if (!isRegularBlob(entry)) throw new Error(`Pinned CoA archive tree is missing a regular file at ${safePath}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
throw new Error(`Pinned CoA archive tree has no valid file size for ${safePath}`);
}
return { path: safePath, gitBlobSha: entry.sha.toLowerCase(), size: entry.size };
}
async function readManifest(archiveRoot) {
try {
return parseJson(await fs.readFile(path.join(archiveRoot, manifestName)), manifestName);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
async function readFileUnder(root, relativePath) {
const safePath = assertSafeRelativePath(relativePath);
const absoluteRoot = path.resolve(root);
const absolutePath = path.resolve(absoluteRoot, ...safePath.split("/"));
if (!absolutePath.startsWith(`${absoluteRoot}${path.sep}`)) {
throw new Error(`CoA archive path escapes its cache directory: ${relativePath}`);
}
return fs.readFile(absolutePath);
}
async function verifyFile(root, fileRecord) {
const bytes = await readFileUnder(root, fileRecord.path);
if (bytes.byteLength !== fileRecord.size) throw new Error(`CoA supplement size mismatch for ${fileRecord.path}`);
if (gitBlobSha(bytes) !== fileRecord.gitBlobSha.toLowerCase()) {
throw new Error(`CoA supplement Git blob checksum mismatch for ${fileRecord.path}`);
}
const actualSha256 = sha256(bytes);
if (actualSha256 !== fileRecord.sha256.toLowerCase()) {
throw new Error(`CoA supplement SHA-256 mismatch for ${fileRecord.path}`);
}
if (fileRecord.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (fileRecord.path === archive.builderSnapshotPath && actualSha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA builder snapshot does not match pinned SHA-256 ${archive.builderSnapshotSha256}`);
}
return bytes;
}
async function loadPublishedCache(archiveRoot) {
const manifest = await readManifest(archiveRoot);
if (manifest === null) return null;
const expected = expectedFilesFromManifest(manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
parseCaptureManifest(contents.get(archive.fetchManifestPath));
const indexPath = "data/talents/coa/index.json";
const index = parseJson(contents.get(indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (classPaths.slice().sort().join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement manifest class files do not match data/talents/coa/index.json");
}
const metadata = {
talents: parseJson(contents.get("data/talents/coa/_meta.json"), "data/talents/coa/_meta.json"),
provenance: parseJson(contents.get("raw/provenance.json"), "raw/provenance.json"),
clientSnapshot: parseJson(contents.get("raw/_snapshot.json"), "raw/_snapshot.json"),
spells: parseJson(contents.get("data/spells/_meta.json"), "data/spells/_meta.json"),
};
const descriptors = descriptorsFromIndex(index);
const rawClasses = classPaths.map((filePath) => parseJson(contents.get(filePath), filePath));
const hasTextReferences = rawClasses.some((definition) => definition.nodes?.some((node) => (
/^\$[0-9a-f]+$/i.test(node.description ?? "")
|| node.rankDescriptions?.some((rank) => /^\$[0-9a-f]+$/i.test(rank.description ?? ""))
)));
const classes = hasTextReferences
? resolveArchiveDescriptions(rawClasses, contents.get(archive.builderSnapshotPath).toString("utf8"))
: rawClasses;
const classFiles = classPaths.map((filePath, position) => ({ path: filePath, indexEntry: descriptors[position], data: classes[position] }));
return {
source: structuredClone(manifest.source),
classes,
classFiles,
metadata,
index,
manifest: structuredClone(manifest),
};
}
async function atomicWrite(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
try {
await fs.writeFile(temporaryPath, bytes, { flag: "wx" });
await fs.rename(temporaryPath, filePath);
} catch (error) {
await fs.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
}
async function discardInvalidFile(filePath) {
try {
await fs.rm(filePath, { force: true });
} catch (error) {
throw new Error(`Could not discard invalid cached CoA supplement ${filePath}: ${error.message}`, { cause: error });
}
}
async function readAndVerifyStaged(stagingRoot, expected) {
let bytes;
try {
bytes = await readFileUnder(stagingRoot, expected.path);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
if (bytes.byteLength !== expected.size || gitBlobSha(bytes) !== expected.gitBlobSha) {
await discardInvalidFile(stagedPath);
return null;
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) {
try {
parseCaptureManifest(bytes);
} catch {
await discardInvalidFile(stagedPath);
return null;
}
}
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
await discardInvalidFile(stagedPath);
return null;
}
return { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
}
async function fetchAndPromoteFile(fetchImpl, stagingRoot, expected) {
const { rawUrl } = archiveUrls(expected.path);
const bytes = await responseBytes(fetchImpl, rawUrl, "application/octet-stream");
if (gitBlobSha(bytes) !== expected.gitBlobSha) {
throw new Error(`Downloaded CoA supplement Git blob checksum mismatch for ${expected.path}`);
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
throw new Error(`Downloaded CoA builder snapshot SHA-256 mismatch for ${expected.path}`);
}
const record = { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
await atomicWrite(stagedPath, bytes);
await verifyFile(stagingRoot, record);
return record;
}
async function stageFile(fetchImpl, stagingRoot, expected) {
const cached = await readAndVerifyStaged(stagingRoot, expected);
if (cached) return { record: cached, downloaded: false };
return { record: await fetchAndPromoteFile(fetchImpl, stagingRoot, expected), downloaded: true };
}
async function validateGeneration(generationRoot, manifest) {
const expected = expectedFilesFromManifest(manifest);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
const index = parseJson(contents.get("data/talents/coa/index.json"), "data/talents/coa/index.json");
const indexedClassPaths = classPathsFromIndex(index).sort();
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (indexedClassPaths.join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement generation class files do not match the pinned index");
}
parseCaptureManifest(contents.get(archive.fetchManifestPath));
for (const filePath of [
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
...indexedClassPaths,
]) {
parseJson(contents.get(filePath), filePath);
}
}
async function writeManifest(archiveRoot, manifest) {
await atomicWrite(
path.join(archiveRoot, manifestName),
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
);
}
function cacheSummary(manifest, cacheDirectory, reused, downloadedFiles = []) {
return {
source: structuredClone(manifest.source),
cacheDirectory,
revision: archive.revision,
capturedAt: archive.capturedAt,
fileCount: manifest.files.length,
classCount: expectedClassCount,
reused,
downloadedFiles,
};
}
async function readSupplements({ cacheRoot } = {}) {
const archiveRoot = archiveDirectory(cacheRoot);
try {
await fs.access(archiveRoot);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
try {
const contents = await loadPublishedCache(archiveRoot);
if (contents === null) throw new Error("published manifest is missing");
return contents;
} catch (error) {
throw new Error(`CoA supplement cache at ${archiveRoot} is corrupt or incomplete: ${error.message}`, { cause: error });
}
}
async function downloadSupplements({ cacheRoot, fetchImpl = globalThis.fetch, refresh = false } = {}) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a fetch-compatible function");
const archiveRoot = archiveDirectory(cacheRoot);
if (!refresh) {
try {
const cached = await loadPublishedCache(archiveRoot);
if (cached) return cacheSummary(cached.manifest, archiveRoot, true);
} catch {
// Keep the previous manifest and generation until a complete replacement is ready.
}
}
await fs.mkdir(archiveRoot, { recursive: true });
const tree = await fetchTree(fetchImpl);
const expected = new Map(requiredPaths.map((filePath) => [filePath, expectedFile(tree, filePath)]));
const stagingRoot = path.join(archiveRoot, "staging");
const stagedRecords = new Map();
const downloadedFiles = [];
for (const filePath of requiredPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const indexPath = "data/talents/coa/index.json";
const index = parseJson(await readFileUnder(stagingRoot, indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
for (const filePath of classPaths) expected.set(filePath, expectedFile(tree, filePath));
for (const filePath of classPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const files = [...stagedRecords.values()].sort((left, right) => left.path.localeCompare(right.path));
if (files.length !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplemental cache expected ${requiredPaths.length + expectedClassCount} files but staged ${files.length}`);
}
parseCaptureManifest(await readFileUnder(stagingRoot, archive.fetchManifestPath));
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(),
generation: `generation-${randomUUID()}`,
files,
};
// Validate every staged byte before moving the resumable cache into an immutable generation.
await validateGeneration(stagingRoot, manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
await fs.rename(stagingRoot, generationRoot);
await writeManifest(archiveRoot, manifest);
return cacheSummary(manifest, archiveRoot, false, downloadedFiles);
}
return Object.freeze({ readSupplements, downloadSupplements });
}
const defaultApi = createSupplementCacheApi();
export const readSupplements = defaultApi.readSupplements;
export const downloadSupplements = defaultApi.downloadSupplements;
export function getDefaultSupplementCacheRoot(projectRoot = defaultProjectRoot) {
return path.join(path.resolve(projectRoot), "data", "coa-reference", "cache");
}