feat(scum): add live data asset manifest contracts

This commit is contained in:
npc0-hue
2026-08-11 18:04:08 +08:00
parent dfd8ca76c9
commit 05ddb3babf
6 changed files with 366 additions and 6 deletions
+137 -2
View File
@@ -1059,19 +1059,41 @@ export function validateSCUMLiveDataManifest(manifest: unknown): string[] {
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[] };
const declaration = manifest as { id?: string; capabilities?: string[]; remoteAccess?: { runCapabilities?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[] }; scumLiveData?: { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[] } };
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[]; 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 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 = (declaration.runtimeProfiles?.transportProfiles ?? []).find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey);
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 {
@@ -1098,6 +1120,119 @@ export function validateSCUMLiveDataManifest(manifest: unknown): string[] {
}
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`);
};
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 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`);
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`);
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"]);
if (template.confirmationSchemaRef && !isSafeRelativeJsonRef(template.confirmationSchemaRef)) errors.push(`${itemLocation}.confirmationSchemaRef: must be a safe relative JSON schema reference`);
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`);
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`);
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`);
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`);
}
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`);
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;
}