Revert SCUM real data management change

This commit is contained in:
npc0-hue
2026-08-13 15:33:34 +08:00
parent d831e4ade9
commit b07a792784
163 changed files with 5443 additions and 9174 deletions
-308
View File
@@ -1,5 +1,4 @@
import fs from "node:fs";
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -14,10 +13,6 @@ function readJson(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function sha256FileDigest(filePath: string): string {
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`;
}
function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): string[] {
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
}
@@ -1059,308 +1054,6 @@ export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
return errors;
}
export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: string): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string };
type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } };
type Transport = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
type DataTarget = { key?: string; kind?: string; transportKey?: string; sourceRootKey?: string; sourcePath?: string; workspaceKey?: string; refreshPolicy?: string; maxBytes?: number; platforms?: string[] };
type RuntimeLogSource = { key?: string };
type AssetFile = { path?: string };
type Asset = { key?: string; adapterVersion?: string; assetPath?: string; digest?: string };
type LogParser = Asset & { parserVersion?: string; sourceKey?: string; eventType?: string; eventSchemaRef?: string; maxLineBytes?: number; cursorPolicy?: string; privacy?: { stripNetworkIdentifiers?: boolean; logicalEventIdentity?: string } };
type SQLiteQuery = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; parameterSchemaRef?: string; resultSchemaRef?: string; maxRows?: number; timeoutMs?: number; maxResultBytes?: number };
type SyncCadence = { capability?: string; intervalSeconds?: number; jitterPercent?: number; timeoutMs?: number; maxConcurrentPerServer?: number };
type TypedRCON = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; permission?: string; payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string; timeoutMs?: number; maxPayloadBytes?: number };
type GuardedMutation = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; permission?: string; payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string; timeoutMs?: number; maxPayloadBytes?: number; maxRowsAffected?: number; safety?: { requiresExpectedChecksum?: boolean; requiresBackupEvidence?: boolean; requiresOfflineOrMaintenance?: boolean; requiresReadAfterWrite?: boolean } };
type MapAsset = Asset & { requiredSchemaFingerprint?: string; metadataSchemaRef?: string; transformAssetPath?: string; transformDigest?: string; worldBounds?: { minX?: number; minY?: number; maxX?: number; maxY?: number }; image?: { width?: number; height?: number } };
type GiftCatalog = Asset & { catalogVersion?: string; itemSchemaRef?: string; transportTemplateKeys?: string[] };
type LiveData = { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[]; logParsers?: LogParser[]; sqliteQueries?: SQLiteQuery[]; syncCadences?: SyncCadence[]; typedRconTemplates?: TypedRCON[]; guardedMutations?: GuardedMutation[]; mapAssets?: MapAsset[]; giftCatalogs?: GiftCatalog[] };
const declaration = manifest as { id?: string; capabilities?: string[]; permissions?: string[]; assetFiles?: AssetFile[]; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[]; dataTargets?: DataTarget[]; logSources?: RuntimeLogSource[] }; scumLiveData?: LiveData };
const liveData = declaration.scumLiveData;
if (!liveData) return [];
const errors: string[] = [];
const declaredCapabilities = new Set(declaration.capabilities ?? []);
const declaredPermissions = new Set(declaration.permissions ?? []);
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
const dataTargets = declaration.runtimeProfiles?.dataTargets ?? [];
const logSources = new Set((declaration.runtimeProfiles?.logSources ?? []).map((source) => source.key ?? ""));
const assetFiles = new Set((declaration.assetFiles ?? []).map((asset) => asset.path ?? ""));
const probe = liveData.probe;
const location = "manifest.scumLiveData";
const digestPattern = /^sha256:[a-fA-F0-9]{64}$/;
const fingerprintPattern = /^(sha256:)?[a-fA-F0-9]{16,128}$/;
const adapterPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/;
const logicalKeyPattern = /^[a-z0-9][a-z0-9._/-]{0,119}$/;
const readCapabilities = new Set(["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"]);
errors.push(...scanUnsafeValues(liveData, location));
if (liveData.schemaVersion !== "1") errors.push(`${location}.schemaVersion: must be 1`);
if (probe?.capability !== "remote.run.db.sqlite.probe") errors.push(`${location}.probe.capability: must be remote.run.db.sqlite.probe`);
if (!declaredCapabilities.has("remote.run.db.sqlite.probe") || !remoteCapabilities.has("remote.run.db.sqlite.probe")) errors.push(`${location}.probe: plugin and remoteAccess must declare remote.run.db.sqlite.probe`);
const transport = transportProfiles.find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey);
if (!transport) {
errors.push(`${location}.probe.targetKey: must reference a declared runtime transport profile or target`);
} else {
if (transport.kind !== "sqlite") errors.push(`${location}.probe.targetKey: schema probe requires a sqlite transport profile`);
if (!transport.capabilities?.includes("remote.run.db.sqlite.probe")) errors.push(`${location}.probe.targetKey: sqlite transport must declare remote.run.db.sqlite.probe`);
}
if ((probe?.bounds?.maxSampleRows ?? 0) > 3) errors.push(`${location}.probe.bounds.maxSampleRows: redacted samples are limited to 3`);
if ((probe?.bounds?.timeoutMs ?? 0) > 10000) errors.push(`${location}.probe.bounds.timeoutMs: must be bounded to 10 seconds or less`);
if ((probe?.bounds?.maxResultBytes ?? 0) > 1048576) errors.push(`${location}.probe.bounds.maxResultBytes: must be bounded to 1 MiB or less`);
const gates = liveData.capabilityGates ?? [];
const seen = new Set<string>();
for (const [index, gate] of gates.entries()) {
const gateLocation = `${location}.capabilityGates[${index}]`;
const capability = gate.capability ?? "";
if (seen.has(capability)) errors.push(`${gateLocation}.capability: duplicate gate ${capability}`);
seen.add(capability);
const reasonErrors = unsafeStringReasons(gate.safeReason ?? "");
errors.push(...reasonErrors.map((reason) => `${gateLocation}.safeReason: ${reason}`));
if (gate.gate === "enabled") {
if (gate.evidenceStatus !== "compatible") errors.push(`${gateLocation}.evidenceStatus: enabled gates require compatible evidence`);
if (!/^(sha256:)?[a-fA-F0-9]{16,128}$/.test(gate.requiredSchemaFingerprint ?? "")) errors.push(`${gateLocation}.requiredSchemaFingerprint: enabled gates require a schema fingerprint`);
if (capability !== "schema-probe" && (!Array.isArray(gate.requiredAssetDigests) || gate.requiredAssetDigests.length === 0)) errors.push(`${gateLocation}.requiredAssetDigests: enabled gates require immutable asset digests`);
}
if (gate.gate === "disabled" && gate.evidenceStatus === "compatible") errors.push(`${gateLocation}.evidenceStatus: disabled gates must not claim compatible evidence`);
}
const requireAsset = (asset: Asset, assetLocation: string): void => {
const key = asset.key ?? "";
if (!logicalKeyPattern.test(key)) errors.push(`${assetLocation}.key: must be a safe logical key`);
if (!adapterPattern.test(asset.adapterVersion ?? "")) errors.push(`${assetLocation}.adapterVersion: must be a bounded adapter version`);
if (!asset.assetPath || !isSafeRelativePathRef(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be a contained package-relative path`);
if (asset.assetPath && !assetFiles.has(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be declared in manifest.assetFiles`);
if (!digestPattern.test(asset.digest ?? "")) errors.push(`${assetLocation}.digest: must be a sha256 digest`);
if (asset.assetPath && asset.digest) validateSCUMAssetDigest(asset.assetPath, asset.digest, `${assetLocation}.digest`);
};
const gateByCapability = new Map(gates.map((gate) => [gate.capability ?? "", gate]));
const requireCapabilityGateMatch = (capability: string | undefined, adapterVersion: string | undefined, schemaFingerprint: string | undefined, digests: Array<string | undefined>, itemLocation: string): void => {
const safeCapability = capability ?? "";
const gate = gateByCapability.get(safeCapability);
if (!gate) {
errors.push(`${itemLocation}: capability ${safeCapability} must have a release gate`);
return;
}
if (gate.adapterVersion !== adapterVersion) errors.push(`${itemLocation}: capability gate adapterVersion must match the asset adapterVersion`);
if (schemaFingerprint && gate.requiredSchemaFingerprint !== schemaFingerprint) errors.push(`${itemLocation}: capability gate schema fingerprint must match the asset requirement`);
const gateDigests = new Set(gate.requiredAssetDigests ?? []);
for (const digest of digests) {
if (digestPattern.test(digest ?? "") && !gateDigests.has(digest ?? "")) errors.push(`${itemLocation}: capability gate must include asset digest ${digest}`);
}
};
const validateSCUMAssetDigest = (assetPath: string, expectedDigest: string, digestLocation: string): void => {
if (!manifestDir || !isSafeRelativePathRef(assetPath) || !digestPattern.test(expectedDigest)) return;
const absoluteAssetPath = path.resolve(manifestDir, assetPath);
if (!fs.existsSync(absoluteAssetPath) || !fs.statSync(absoluteAssetPath).isFile()) {
errors.push(`${digestLocation}: missing packaged asset file ${assetPath}`);
return;
}
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(absoluteAssetPath));
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
errors.push(`${digestLocation}: asset file must remain inside the plugin manifest directory`);
return;
}
if (sha256FileDigest(absoluteAssetPath) !== expectedDigest) errors.push(`${digestLocation}: digest does not match packaged asset content`);
};
const readSCUMJSONAsset = (assetPath: string | undefined, assetLocation: string): Record<string, unknown> | undefined => {
if (!manifestDir || !assetPath || !isSafeRelativePathRef(assetPath)) return undefined;
const absoluteAssetPath = path.resolve(manifestDir, assetPath);
if (!fs.existsSync(absoluteAssetPath) || !fs.statSync(absoluteAssetPath).isFile()) return undefined;
try {
const value = readJson(absoluteAssetPath);
if (!value || typeof value !== "object" || Array.isArray(value)) {
errors.push(`${assetLocation}: asset content must be a JSON object`);
return undefined;
}
return value as Record<string, unknown>;
} catch {
errors.push(`${assetLocation}: asset content must be valid JSON`);
return undefined;
}
};
const mapBoundsMatch = (candidate: unknown, bounds: MapAsset["worldBounds"]): boolean => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || !bounds) return false;
const value = candidate as { minX?: unknown; minY?: unknown; maxX?: unknown; maxY?: unknown };
return value.minX === bounds.minX && value.minY === bounds.minY && value.maxX === bounds.maxX && value.maxY === bounds.maxY;
};
const mapImageMatch = (candidate: unknown, image: MapAsset["image"]): boolean => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || !image) return false;
const value = candidate as { width?: unknown; height?: unknown };
return value.width === image.width && value.height === image.height;
};
const requireSCUMMapAssetJSONCompatibility = (mapAsset: MapAsset, itemLocation: string): void => {
const metadata = readSCUMJSONAsset(mapAsset.assetPath, `${itemLocation}.assetPath`);
if (metadata) {
if (metadata.key !== mapAsset.key) errors.push(`${itemLocation}.assetPath: metadata key must match the map asset declaration`);
if (!adapterPattern.test(String(metadata.mapVersion ?? ""))) errors.push(`${itemLocation}.assetPath: metadata mapVersion must be a bounded version`);
if (metadata.adapterVersion !== mapAsset.adapterVersion) errors.push(`${itemLocation}.assetPath: metadata adapterVersion must match the map asset declaration`);
if (metadata.requiredSchemaFingerprint !== mapAsset.requiredSchemaFingerprint) errors.push(`${itemLocation}.assetPath: metadata schema fingerprint must match the map asset declaration`);
if (metadata.transformAssetPath !== mapAsset.transformAssetPath) errors.push(`${itemLocation}.assetPath: metadata transformAssetPath must match the map asset declaration`);
if (!mapBoundsMatch(metadata.worldBounds, mapAsset.worldBounds)) errors.push(`${itemLocation}.assetPath: metadata worldBounds must match the map asset declaration`);
if (!mapImageMatch(metadata.image, mapAsset.image)) errors.push(`${itemLocation}.assetPath: metadata image dimensions must match the map asset declaration`);
const layers = metadata.layers;
if (!Array.isArray(layers) || layers.length === 0) {
errors.push(`${itemLocation}.assetPath: metadata must declare at least one map layer`);
} else {
const layerKeys = new Set<string>();
for (const [layerIndex, layer] of layers.entries()) {
const layerLocation = `${itemLocation}.assetPath.layers[${layerIndex}]`;
if (!layer || typeof layer !== "object" || Array.isArray(layer)) {
errors.push(`${layerLocation}: layer metadata must be an object`);
continue;
}
const candidate = layer as { key?: unknown; capability?: unknown; subjectType?: unknown; sourceQueryKey?: unknown };
const layerKey = String(candidate.key ?? "");
if (layerKeys.has(layerKey)) errors.push(`${layerLocation}.key: duplicate layer key ${layerKey}`);
layerKeys.add(layerKey);
if (!logicalKeyPattern.test(layerKey)) errors.push(`${layerLocation}.key: must be a safe logical key`);
if (candidate.capability !== "positions.read") errors.push(`${layerLocation}.capability: map layers must use positions.read`);
if (!["player", "vehicle", "flag"].includes(String(candidate.subjectType ?? ""))) errors.push(`${layerLocation}.subjectType: must be player, vehicle, or flag`);
if (candidate.sourceQueryKey !== "scum-positions-read") errors.push(`${layerLocation}.sourceQueryKey: must reference the packaged positions query`);
}
}
}
const transform = readSCUMJSONAsset(mapAsset.transformAssetPath, `${itemLocation}.transformAssetPath`);
if (transform) {
if (transform.mapAssetKey !== mapAsset.key) errors.push(`${itemLocation}.transformAssetPath: transform mapAssetKey must match the map asset declaration`);
if (!adapterPattern.test(String(transform.transformVersion ?? ""))) errors.push(`${itemLocation}.transformAssetPath: transformVersion must be a bounded version`);
if (transform.adapterVersion !== mapAsset.adapterVersion) errors.push(`${itemLocation}.transformAssetPath: transform adapterVersion must match the map asset declaration`);
if (transform.requiredSchemaFingerprint !== mapAsset.requiredSchemaFingerprint) errors.push(`${itemLocation}.transformAssetPath: transform schema fingerprint must match the map asset declaration`);
if (!mapBoundsMatch(transform.worldBounds, mapAsset.worldBounds)) errors.push(`${itemLocation}.transformAssetPath: transform worldBounds must match the map asset declaration`);
if (!mapImageMatch(transform.image, mapAsset.image)) errors.push(`${itemLocation}.transformAssetPath: transform image dimensions must match the map asset declaration`);
const validation = transform.validation as { rejectNonFinite?: unknown; rejectOutOfBounds?: unknown; acceptBoundaryPoints?: unknown } | undefined;
if (validation?.rejectNonFinite !== true || validation?.rejectOutOfBounds !== true || validation?.acceptBoundaryPoints !== true) errors.push(`${itemLocation}.transformAssetPath.validation: must reject non-finite/out-of-bounds coordinates and accept boundary points`);
if (!Array.isArray(transform.fixtures) || transform.fixtures.length === 0) errors.push(`${itemLocation}.transformAssetPath.fixtures: transform must include known-point fixtures`);
}
};
const requireUniqueAssetKeys = (assets: Asset[] | undefined, collection: string): void => {
const seenKeys = new Set<string>();
for (const [index, asset] of (assets ?? []).entries()) {
const key = asset.key ?? "";
if (seenKeys.has(key)) errors.push(`${location}.${collection}[${index}].key: duplicate asset key ${key}`);
seenKeys.add(key);
}
};
const transportByKey = (key?: string): Transport | undefined => transportProfiles.find((candidate) => candidate.key === key);
const probeDataTarget = dataTargets.find((candidate) => candidate.key === probe?.targetKey);
const expectedProbeWorkspaceKey = `databases/${String(probe?.targetKey ?? "").replace(/^databases\//, "")}`;
if (!probeDataTarget) {
errors.push(`${location}.probe.targetKey: must reference a declared runtime data target`);
} else {
const probeDataTargetTransport = transportByKey(probeDataTarget.transportKey);
if (probeDataTarget.kind !== "sqlite.snapshot" || probeDataTarget.workspaceKey !== expectedProbeWorkspaceKey || probeDataTarget.refreshPolicy !== "on-demand-snapshot" || !probeDataTargetTransport || probeDataTargetTransport.kind !== "sqlite" || !probeDataTargetTransport.capabilities?.includes("remote.run.db.sqlite.probe")) {
errors.push(`${location}.probe.targetKey: must reference a sqlite snapshot data target for the generated Run workspace`);
}
if (!Number.isInteger(probeDataTarget.maxBytes) || (probeDataTarget.maxBytes ?? 0) < 1 || (probeDataTarget.maxBytes ?? 0) > 1073741824) errors.push(`${location}.probe.targetKey.maxBytes: must be between 1 and 1073741824`);
}
const requireRefs = (entry: Record<string, unknown>, entryLocation: string, refs: string[]): void => {
for (const field of refs) {
const ref = entry[field];
if (typeof ref !== "string" || !isSafeRelativeJsonRef(ref)) errors.push(`${entryLocation}.${field}: must be a safe relative JSON schema reference`);
}
};
requireUniqueAssetKeys(liveData.logParsers, "logParsers");
for (const [index, parser] of (liveData.logParsers ?? []).entries()) {
const itemLocation = `${location}.logParsers[${index}]`;
requireAsset(parser, itemLocation);
if (!adapterPattern.test(parser.parserVersion ?? "")) errors.push(`${itemLocation}.parserVersion: must be a bounded parser version`);
if (!logSources.has(parser.sourceKey ?? "")) errors.push(`${itemLocation}.sourceKey: must reference a declared runtime log source`);
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(parser.eventType ?? "")) errors.push(`${itemLocation}.eventType: must be a safe event type`);
requireRefs(parser as Record<string, unknown>, itemLocation, ["eventSchemaRef"]);
if (!Number.isInteger(parser.maxLineBytes) || (parser.maxLineBytes ?? 0) < 1 || (parser.maxLineBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxLineBytes: must be between 1 and 65536`);
if (parser.cursorPolicy !== "source-generation-sequence") errors.push(`${itemLocation}.cursorPolicy: must be source-generation-sequence`);
if (parser.privacy?.stripNetworkIdentifiers !== true || parser.privacy?.logicalEventIdentity !== "native-or-sanitized-fields") errors.push(`${itemLocation}.privacy: must strip network identifiers and use a privacy-safe logical identity`);
}
requireUniqueAssetKeys(liveData.sqliteQueries, "sqliteQueries");
for (const [index, query] of (liveData.sqliteQueries ?? []).entries()) {
const itemLocation = `${location}.sqliteQueries[${index}]`;
requireAsset(query, itemLocation);
if (!readCapabilities.has(query.capability ?? "")) errors.push(`${itemLocation}.capability: must be a read capability`);
if (!fingerprintPattern.test(query.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch(query.capability, query.adapterVersion, query.requiredSchemaFingerprint, [query.digest], itemLocation);
requireRefs(query as Record<string, unknown>, itemLocation, ["parameterSchemaRef", "resultSchemaRef"]);
if (!Number.isInteger(query.maxRows) || (query.maxRows ?? 0) < 1 || (query.maxRows ?? 0) > 500) errors.push(`${itemLocation}.maxRows: must be between 1 and 500`);
if (!Number.isInteger(query.timeoutMs) || (query.timeoutMs ?? 0) < 1 || (query.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(query.maxResultBytes) || (query.maxResultBytes ?? 0) < 1 || (query.maxResultBytes ?? 0) > 1048576) errors.push(`${itemLocation}.maxResultBytes: must be between 1 and 1048576`);
const queryTransport = transportByKey(query.transportKey);
if (!queryTransport || queryTransport.kind !== "sqlite" || !queryTransport.capabilities?.includes("remote.run.db.sqlite.query") || queryTransport.targetKey !== query.targetKey) errors.push(`${itemLocation}.transportKey: must reference sqlite transport with remote.run.db.sqlite.query and matching targetKey`);
if (!declaredCapabilities.has("remote.run.db.sqlite.query") || !remoteCapabilities.has("remote.run.db.sqlite.query") || !remoteDatabaseEngines.has("sqlite")) errors.push(`${itemLocation}: sqlite query assets require plugin and remote-access sqlite query capability`);
}
for (const [index, cadence] of (liveData.syncCadences ?? []).entries()) {
const itemLocation = `${location}.syncCadences[${index}]`;
if (!readCapabilities.has(cadence.capability ?? "")) errors.push(`${itemLocation}.capability: must be a read capability`);
if (!Number.isInteger(cadence.intervalSeconds) || (cadence.intervalSeconds ?? 0) < 5 || (cadence.intervalSeconds ?? 0) > 86400) errors.push(`${itemLocation}.intervalSeconds: must be between 5 and 86400`);
if (!Number.isInteger(cadence.jitterPercent) || (cadence.jitterPercent ?? -1) < 0 || (cadence.jitterPercent ?? 101) > 100) errors.push(`${itemLocation}.jitterPercent: must be between 0 and 100`);
if (!Number.isInteger(cadence.timeoutMs) || (cadence.timeoutMs ?? 0) < 1 || (cadence.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(cadence.maxConcurrentPerServer) || (cadence.maxConcurrentPerServer ?? 0) < 1 || (cadence.maxConcurrentPerServer ?? 0) > 16) errors.push(`${itemLocation}.maxConcurrentPerServer: must be between 1 and 16`);
}
requireUniqueAssetKeys(liveData.typedRconTemplates, "typedRconTemplates");
const typedRCONKeys = new Set((liveData.typedRconTemplates ?? []).map((template) => template.key ?? ""));
for (const [index, template] of (liveData.typedRconTemplates ?? []).entries()) {
const itemLocation = `${location}.typedRconTemplates[${index}]`;
requireAsset(template, itemLocation);
if (!["economy-command.write", "gift-command.write"].includes(template.capability ?? "")) errors.push(`${itemLocation}.capability: must be economy-command.write or gift-command.write`);
if (template.requiredSchemaFingerprint && !fingerprintPattern.test(template.requiredSchemaFingerprint)) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch(template.capability, template.adapterVersion, template.requiredSchemaFingerprint, [template.digest], itemLocation);
if (template.permission !== "server.game-client.command" || !declaredPermissions.has("server.game-client.command")) errors.push(`${itemLocation}.permission: must require declared server.game-client.command`);
requireRefs(template as Record<string, unknown>, itemLocation, ["payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef"]);
if (!Number.isInteger(template.timeoutMs) || (template.timeoutMs ?? 0) < 1 || (template.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(template.maxPayloadBytes) || (template.maxPayloadBytes ?? 0) < 1 || (template.maxPayloadBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxPayloadBytes: must be between 1 and 65536`);
const rconTransport = transportByKey(template.transportKey);
if (!rconTransport || rconTransport.kind !== "rcon" || !rconTransport.capabilities?.includes("remote.run.protected.rcon") || rconTransport.targetKey !== template.targetKey) errors.push(`${itemLocation}.transportKey: must reference rcon transport with remote.run.protected.rcon and matching targetKey`);
if (!declaredCapabilities.has("remote.run.protected.rcon") || !remoteCapabilities.has("remote.run.protected.rcon")) errors.push(`${itemLocation}: typed RCON templates require plugin and remote-access protected RCON capability`);
}
requireUniqueAssetKeys(liveData.guardedMutations, "guardedMutations");
for (const [index, mutation] of (liveData.guardedMutations ?? []).entries()) {
const itemLocation = `${location}.guardedMutations[${index}]`;
requireAsset(mutation, itemLocation);
if (mutation.capability !== "profile-xml.write") errors.push(`${itemLocation}.capability: must be profile-xml.write`);
if (!fingerprintPattern.test(mutation.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch(mutation.capability, mutation.adapterVersion, mutation.requiredSchemaFingerprint, [mutation.digest], itemLocation);
if (mutation.permission !== "server.game-client.maintenance" || !declaredPermissions.has("server.game-client.maintenance")) errors.push(`${itemLocation}.permission: must require declared server.game-client.maintenance`);
requireRefs(mutation as Record<string, unknown>, itemLocation, ["payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef"]);
if (!Number.isInteger(mutation.timeoutMs) || (mutation.timeoutMs ?? 0) < 1 || (mutation.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`);
if (!Number.isInteger(mutation.maxPayloadBytes) || (mutation.maxPayloadBytes ?? 0) < 1 || (mutation.maxPayloadBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxPayloadBytes: must be between 1 and 65536`);
if (mutation.maxRowsAffected !== 1) errors.push(`${itemLocation}.maxRowsAffected: must be exactly 1`);
if (mutation.safety?.requiresExpectedChecksum !== true || mutation.safety?.requiresBackupEvidence !== true || mutation.safety?.requiresOfflineOrMaintenance !== true || mutation.safety?.requiresReadAfterWrite !== true) errors.push(`${itemLocation}.safety: must require checksum, backup evidence, offline/maintenance, and read-after-write`);
const mutationTransport = transportByKey(mutation.transportKey);
if (!mutationTransport || mutationTransport.kind !== "sqlite" || !mutationTransport.capabilities?.includes("remote.run.protected.sql") || mutationTransport.targetKey !== mutation.targetKey) errors.push(`${itemLocation}.transportKey: must reference sqlite transport with remote.run.protected.sql and matching targetKey`);
if (!declaredCapabilities.has("remote.run.protected.sql") || !remoteCapabilities.has("remote.run.protected.sql") || !remoteDatabaseEngines.has("sqlite")) errors.push(`${itemLocation}: guarded mutations require plugin and remote-access protected sqlite capability`);
}
requireUniqueAssetKeys(liveData.mapAssets, "mapAssets");
for (const [index, mapAsset] of (liveData.mapAssets ?? []).entries()) {
const itemLocation = `${location}.mapAssets[${index}]`;
requireAsset(mapAsset, itemLocation);
if (!fingerprintPattern.test(mapAsset.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`);
requireCapabilityGateMatch("positions.read", mapAsset.adapterVersion, mapAsset.requiredSchemaFingerprint, [mapAsset.digest, mapAsset.transformDigest], itemLocation);
requireRefs(mapAsset as Record<string, unknown>, itemLocation, ["metadataSchemaRef"]);
if (!mapAsset.transformAssetPath || !isSafeRelativePathRef(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be a contained package-relative path`);
if (mapAsset.transformAssetPath && !assetFiles.has(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be declared in manifest.assetFiles`);
if (!digestPattern.test(mapAsset.transformDigest ?? "")) errors.push(`${itemLocation}.transformDigest: must be a sha256 digest`);
if (mapAsset.transformAssetPath && mapAsset.transformDigest) validateSCUMAssetDigest(mapAsset.transformAssetPath, mapAsset.transformDigest, `${itemLocation}.transformDigest`);
const bounds = mapAsset.worldBounds;
if (!bounds || !Number.isFinite(bounds.minX) || !Number.isFinite(bounds.minY) || !Number.isFinite(bounds.maxX) || !Number.isFinite(bounds.maxY) || (bounds.minX ?? 0) >= (bounds.maxX ?? 0) || (bounds.minY ?? 0) >= (bounds.maxY ?? 0)) errors.push(`${itemLocation}.worldBounds: must define finite increasing bounds`);
if (!Number.isInteger(mapAsset.image?.width) || (mapAsset.image?.width ?? 0) < 1 || !Number.isInteger(mapAsset.image?.height) || (mapAsset.image?.height ?? 0) < 1) errors.push(`${itemLocation}.image: width and height must be positive integers`);
requireSCUMMapAssetJSONCompatibility(mapAsset, itemLocation);
}
requireUniqueAssetKeys(liveData.giftCatalogs, "giftCatalogs");
for (const [index, catalog] of (liveData.giftCatalogs ?? []).entries()) {
const itemLocation = `${location}.giftCatalogs[${index}]`;
requireAsset(catalog, itemLocation);
if (!adapterPattern.test(catalog.catalogVersion ?? "")) errors.push(`${itemLocation}.catalogVersion: must be a bounded catalog version`);
requireCapabilityGateMatch("gift-command.write", catalog.adapterVersion, undefined, [catalog.digest], itemLocation);
requireRefs(catalog as Record<string, unknown>, itemLocation, ["itemSchemaRef"]);
if (!Array.isArray(catalog.transportTemplateKeys) || catalog.transportTemplateKeys.length === 0) errors.push(`${itemLocation}.transportTemplateKeys: must reference at least one typed RCON transport template`);
for (const templateKey of catalog.transportTemplateKeys ?? []) {
if (!typedRCONKeys.has(templateKey)) errors.push(`${itemLocation}.transportTemplateKeys: undeclared typed RCON template ${templateKey}`);
}
}
return errors;
}
type GameClientBridgeSchemaReference = {
location: string;
ref: string;
@@ -1679,7 +1372,6 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateClientManagerProfiles(manifest));
errors.push(...validateDLLExtensionProfiles(manifest));
errors.push(...validateGameClientBridgeCatalog(manifest));
errors.push(...validateSCUMLiveDataManifest(manifest, manifestDir));
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
errors.push(...validateRuntimeLogEventCatalog(manifest));