1730 lines
99 KiB
TypeScript
1730 lines
99 KiB
TypeScript
import fs from "node:fs";
|
|
import crypto from "node:crypto";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
import { Ajv2020, type AnySchema, type ErrorObject } from "ajv/dist/2020.js";
|
|
|
|
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
|
const manifestSchemaPath = path.join(rootDir, "manifests", "game-plugin.manifest.schema.json");
|
|
const lifecycleActionSchemaPath = path.join(rootDir, "manifests", "lifecycle-action.schema.json");
|
|
const createFormSchemaPath = path.join(rootDir, "schemas", "create-form.schema.json");
|
|
|
|
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}`);
|
|
}
|
|
|
|
function unsafeFieldReason(fieldName: string): string | undefined {
|
|
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
|
|
return "raw AI/provider key field is not allowed";
|
|
}
|
|
if (compact.includes("runcredential") || compact.includes("runsocket") || compact.includes("directrun")) {
|
|
return "direct run access field is not allowed";
|
|
}
|
|
if (compact.includes("password") || compact.includes("dsn") || compact.includes("rawkey") || compact.includes("secretkey") || compact.includes("credential")) {
|
|
return "raw credential field is not allowed";
|
|
}
|
|
if (compact.includes("hostpath") || compact.includes("rawpath")) {
|
|
return "raw host path field is not allowed";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function unsafeStringReasons(value: string): string[] {
|
|
const trimmed = value.trim();
|
|
const lowered = trimmed.toLowerCase();
|
|
if (trimmed === "") {
|
|
return [];
|
|
}
|
|
|
|
const reasons: string[] = [];
|
|
if (
|
|
lowered.startsWith("sk-") ||
|
|
lowered.startsWith("sk_") ||
|
|
lowered.includes("bearer ") ||
|
|
lowered.includes("api_key=") ||
|
|
lowered.includes("apikey=") ||
|
|
lowered.includes("raw api key") ||
|
|
lowered.includes("raw credential") ||
|
|
lowered.includes("provider key") ||
|
|
lowered.includes("ai key") ||
|
|
lowered.includes("password=") ||
|
|
lowered.includes("rcon password") ||
|
|
lowered.includes("ftp password") ||
|
|
lowered.startsWith("mysql://") ||
|
|
lowered.startsWith("sqlite://") ||
|
|
lowered.includes("database dsn")
|
|
) {
|
|
reasons.push("raw credential or AI/provider key content is not allowed");
|
|
}
|
|
if (
|
|
lowered.includes("direct run") ||
|
|
lowered.includes("run socket") ||
|
|
lowered.includes("run credential") ||
|
|
lowered.includes("run token") ||
|
|
lowered.includes("direct socket") ||
|
|
lowered.startsWith("tcp://") ||
|
|
lowered.startsWith("unix://")
|
|
) {
|
|
reasons.push("direct run access request is not allowed");
|
|
}
|
|
if (
|
|
lowered.startsWith("file://") ||
|
|
lowered.startsWith("\\\\") ||
|
|
/^[a-z]:[\\/]/i.test(trimmed) ||
|
|
/^\/(users|etc|var|tmp|home|root|private|volumes|opt)\//i.test(trimmed) ||
|
|
lowered.includes("host path") ||
|
|
lowered.includes("raw host path")
|
|
) {
|
|
reasons.push("raw host path access is not allowed");
|
|
}
|
|
if (
|
|
lowered.includes("shell script") ||
|
|
lowered.includes("bash -c") ||
|
|
lowered.includes("powershell -") ||
|
|
lowered.includes("cmd.exe") ||
|
|
lowered.includes("curl |")
|
|
) {
|
|
reasons.push("arbitrary shell content is not allowed");
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
function scanUnsafeValues(value: unknown, location: string): string[] {
|
|
if (typeof value === "string") {
|
|
return unsafeStringReasons(value).map((reason) => `${location}: ${reason}`);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) => scanUnsafeValues(item, `${location}[${index}]`));
|
|
}
|
|
if (typeof value === "object" && value !== null) {
|
|
return Object.entries(value).flatMap(([key, child]) => {
|
|
const keyReason = unsafeFieldReason(key);
|
|
const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : [];
|
|
return [...keyErrors, ...scanUnsafeValues(child, `${location}.${key}`)];
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function validateCreateFieldDeclarations(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return [];
|
|
const server = (manifest as { server?: { createFields?: Array<{ key?: unknown; type?: unknown; defaultValue?: unknown; options?: unknown }> } }).server;
|
|
if (!Array.isArray(server?.createFields)) return [];
|
|
const errors: string[] = [];
|
|
for (const [index, field] of server.createFields.entries()) {
|
|
const location = `manifest.server.createFields[${index}]`;
|
|
for (const [name, value] of [["defaultValue", field.defaultValue], ["options", field.options]] as const) {
|
|
const values = Array.isArray(value) ? value : [value];
|
|
for (const item of values) {
|
|
if (typeof item !== "string") continue;
|
|
if (item.startsWith("/") || item.startsWith("\\\\") || /^[a-z]:[\\/]/i.test(item)) errors.push(`${location}.${name}: raw host path access is not allowed`);
|
|
errors.push(...unsafeStringReasons(item).map((reason) => `${location}.${name}: ${reason}`));
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function isSafeRelativeJsonRef(value: string): boolean {
|
|
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
|
|
}
|
|
|
|
function isSafeRelativePathRef(value: string): boolean {
|
|
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
|
|
}
|
|
|
|
function identifierTokens(value: string): string[] {
|
|
return value
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9]+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefined {
|
|
const tokens = identifierTokens(value);
|
|
const tokenSet = new Set(tokens);
|
|
if (
|
|
tokenSet.has("sql") ||
|
|
((tokenSet.has("database") || tokenSet.has("db")) && tokens.some((token) => ["execute", "exec", "eval", "run", "query", "statement"].includes(token))) ||
|
|
(tokenSet.has("query") && tokens.some((token) => ["execute", "exec", "eval", "raw", "statement"].includes(token)))
|
|
) {
|
|
return "arbitrary SQL or database execution command declarations are not allowed";
|
|
}
|
|
if (
|
|
tokens.some((token) => token === "shell" || token === "powershell" || token === "script" || token === "terminal") ||
|
|
tokens.some((token) => token === "execute" || token === "exec" || token === "eval") ||
|
|
((tokenSet.has("command") || tokenSet.has("cmd") || tokenSet.has("process") || tokenSet.has("system") || tokenSet.has("os") || tokenSet.has("executor")) && tokenSet.has("run"))
|
|
) {
|
|
return "arbitrary shell, script, terminal, or generic execution command declarations are not allowed";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function unsafeGameClientBridgePayloadKey(value: string): boolean {
|
|
const tokens = identifierTokens(value);
|
|
const compact = tokens.join("");
|
|
return ["sql", "rawsql", "sqltext", "sqlstatement", "dsn", "hostpath", "socket", "credential", "accesstoken"].includes(compact);
|
|
}
|
|
|
|
function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined {
|
|
const tokens = identifierTokens(fieldName);
|
|
const compact = tokens.join("");
|
|
const generalReason = unsafeFieldReason(fieldName);
|
|
if (generalReason) {
|
|
return generalReason;
|
|
}
|
|
if ((tokens.includes("sql") || tokens.includes("query")) && tokens.includes("template") && (tokens.includes("key") || tokens.includes("ref"))) {
|
|
return undefined;
|
|
}
|
|
if (["sql", "rawsql", "sqltext", "sqlquery", "sqlstatement", "rawquery", "statement"].includes(compact)) {
|
|
return "arbitrary SQL field is not allowed";
|
|
}
|
|
if (["shell", "shellcommand", "shellscript", "script", "scriptbody", "terminalcommand", "commandline", "powershell"].includes(compact)) {
|
|
return "arbitrary shell or script field is not allowed";
|
|
}
|
|
if (["hostpath", "rawpath", "absolutepath", "filesystempath"].includes(compact)) {
|
|
return "raw host path field is not allowed";
|
|
}
|
|
if (["runcapability", "executorcapability", "runendpoint", "runsocket", "directrun"].includes(compact)) {
|
|
return "unsafe executor capability or direct Run field is not allowed";
|
|
}
|
|
if (tokens.some((token) => ["socket", "password", "credential", "secret", "token", "dsn"].includes(token))) {
|
|
return "direct socket or raw credential field is not allowed";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function unsafeBridgeSchemaStringReasons(value: string): string[] {
|
|
const reasons = [...unsafeStringReasons(value)];
|
|
const trimmed = value.trim();
|
|
const fieldReason = unsafeBridgeSchemaFieldReason(trimmed);
|
|
if (fieldReason) {
|
|
reasons.push(fieldReason);
|
|
}
|
|
if (/\bselect\b[\s\S]{0,240}\bfrom\b/i.test(trimmed) || /\b(?:insert\s+into|update\s+[a-z0-9_.]+\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|attach\s+database|pragma\s+[a-z0-9_]+)/i.test(trimmed)) {
|
|
reasons.push("arbitrary SQL content is not allowed");
|
|
}
|
|
if (/^\s*(?:sh|bash|zsh|powershell|pwsh)\s+-[a-z]*c\b/i.test(trimmed) || /^\s*cmd(?:\.exe)?\s+\/c\b/i.test(trimmed)) {
|
|
reasons.push("arbitrary shell content is not allowed");
|
|
}
|
|
if (/^(?:run|executor|shell|script|terminal)\.(?:socket|endpoint|exec|execute|command)$/i.test(trimmed)) {
|
|
reasons.push("unsafe executor capability is not allowed");
|
|
}
|
|
return [...new Set(reasons)];
|
|
}
|
|
|
|
function scanUnsafeBridgeSchema(value: unknown, location: string): string[] {
|
|
if (typeof value === "string") {
|
|
return unsafeBridgeSchemaStringReasons(value).map((reason) => `${location}: ${reason}`);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) => scanUnsafeBridgeSchema(item, `${location}[${index}]`));
|
|
}
|
|
if (typeof value === "object" && value !== null) {
|
|
return Object.entries(value).flatMap(([key, child]) => {
|
|
const keyReason = unsafeBridgeSchemaFieldReason(key);
|
|
const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : [];
|
|
return [...keyErrors, ...scanUnsafeBridgeSchema(child, `${location}.${key}`)];
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function validateBoundedBridgeSchema(value: unknown, location: string): string[] {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
return [`${location}: bridge schema root must be an object schema`];
|
|
}
|
|
const root = value as Record<string, unknown>;
|
|
const errors: string[] = [];
|
|
if (root.type !== "object") {
|
|
errors.push(`${location}: bridge schema root type must be object`);
|
|
}
|
|
const visit = (node: unknown, nodeLocation: string): void => {
|
|
if (Array.isArray(node)) {
|
|
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
|
return;
|
|
}
|
|
if (typeof node !== "object" || node === null) {
|
|
return;
|
|
}
|
|
const record = node as Record<string, unknown>;
|
|
if ((record.type === "object" || Object.hasOwn(record, "properties")) && record.additionalProperties !== false) {
|
|
errors.push(`${nodeLocation}.additionalProperties: bounded object schemas must set additionalProperties to false`);
|
|
}
|
|
for (const [key, child] of Object.entries(record)) {
|
|
visit(child, `${nodeLocation}.${key}`);
|
|
}
|
|
};
|
|
visit(root, location);
|
|
return errors;
|
|
}
|
|
|
|
function unsafeSemanticLogEventTypeReason(value: string): string | undefined {
|
|
const tokens = identifierTokens(value);
|
|
const tokenSet = new Set(tokens);
|
|
if (
|
|
tokens.some((token) => ["shell", "powershell", "script", "terminal", "execute", "exec", "eval"].includes(token)) ||
|
|
tokens.some((token) => ["credential", "password", "secret", "socket"].includes(token)) ||
|
|
(tokenSet.has("run") && (tokenSet.has("direct") || tokenSet.has("socket"))) ||
|
|
(tokenSet.has("path") && (tokenSet.has("host") || tokenSet.has("raw"))) ||
|
|
(tokenSet.has("sql") && tokens.some((token) => ["query", "statement", "raw", "execute", "exec"].includes(token)))
|
|
) {
|
|
return "unsafe SQL, shell, path, credential, or socket event types are not allowed";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function validateBoundedSemanticLogSchema(value: unknown, location: string): string[] {
|
|
const errors = validateBoundedBridgeSchema(value, location);
|
|
const visit = (node: unknown, nodeLocation: string): void => {
|
|
if (Array.isArray(node)) {
|
|
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
|
return;
|
|
}
|
|
if (typeof node !== "object" || node === null) {
|
|
return;
|
|
}
|
|
const record = node as Record<string, unknown>;
|
|
if (record.type === "array") {
|
|
if (!Number.isInteger(record.maxItems) || (record.maxItems as number) < 1 || (record.maxItems as number) > 1000) {
|
|
errors.push(`${nodeLocation}.maxItems: bounded event arrays must set maxItems between 1 and 1000`);
|
|
}
|
|
}
|
|
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
|
if (!Number.isInteger(record.maxLength) || (record.maxLength as number) < 1 || (record.maxLength as number) > 4096) {
|
|
errors.push(`${nodeLocation}.maxLength: bounded event strings must set maxLength between 1 and 4096`);
|
|
}
|
|
}
|
|
if (record.type === "integer" || record.type === "number") {
|
|
if (typeof record.minimum !== "number" || !Number.isFinite(record.minimum) || typeof record.maximum !== "number" || !Number.isFinite(record.maximum)) {
|
|
errors.push(`${nodeLocation}: bounded event numbers must set finite minimum and maximum values`);
|
|
} else if (record.minimum > record.maximum) {
|
|
errors.push(`${nodeLocation}: event number minimum must not exceed maximum`);
|
|
}
|
|
}
|
|
for (const [key, child] of Object.entries(record)) {
|
|
visit(child, `${nodeLocation}.${key}`);
|
|
}
|
|
};
|
|
visit(value, location);
|
|
return errors;
|
|
}
|
|
|
|
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
|
const action = readJson(path.resolve(rootDir, actionPath));
|
|
const ajv = new Ajv2020({ allErrors: true });
|
|
ajv.addSchema(readJson(manifestSchemaPath) as AnySchema);
|
|
const validateAction = ajv.compile(readJson(lifecycleActionSchemaPath) as AnySchema);
|
|
const errors = validateAction(action) ? [] : formatErrors("lifecycleAction", validateAction.errors);
|
|
errors.push(...scanUnsafeValues(action, "lifecycleAction"));
|
|
if (typeof action !== "object" || action === null) {
|
|
return errors;
|
|
}
|
|
const declaration = action as { action?: string; mode?: string; executableKey?: string };
|
|
if (expectedAction && declaration.action !== expectedAction) {
|
|
errors.push(`lifecycleAction.action: expected ${expectedAction}`);
|
|
}
|
|
if ((declaration.action === "install" || declaration.action === "start") && !declaration.executableKey) {
|
|
errors.push("lifecycleAction.executableKey: required for install/start");
|
|
}
|
|
if (declaration.action === "start" && declaration.mode !== "supervised") {
|
|
errors.push("lifecycleAction.mode: start must be supervised");
|
|
}
|
|
if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") {
|
|
errors.push(`lifecycleAction.mode: ${declaration.action} must be control`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function referencedLifecycleActions(manifest: unknown): Array<{ action: string; ref: string }> {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
const record = manifest as {
|
|
actions?: Record<string, string>;
|
|
runtimeProfiles?: { lifecycleProfiles?: Array<{ actionRefs?: Record<string, string> }> };
|
|
};
|
|
const refs = new Map<string, { action: string; ref: string }>();
|
|
for (const [action, ref] of Object.entries(record.actions ?? {})) {
|
|
refs.set(`${action}:${ref}`, { action, ref });
|
|
}
|
|
for (const profile of record.runtimeProfiles?.lifecycleProfiles ?? []) {
|
|
for (const [action, ref] of Object.entries(profile.actionRefs ?? {})) {
|
|
refs.set(`${action}:${ref}`, { action, ref });
|
|
}
|
|
}
|
|
return [...refs.values()];
|
|
}
|
|
|
|
type PluginAssetFileDeclaration = { path?: unknown; mode?: unknown };
|
|
|
|
function declaredAssetFiles(manifest: unknown): PluginAssetFileDeclaration[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
const assetFiles = (manifest as { assetFiles?: unknown }).assetFiles;
|
|
return Array.isArray(assetFiles) ? (assetFiles as PluginAssetFileDeclaration[]) : [];
|
|
}
|
|
|
|
function validateManifestAssetFiles(manifest: unknown, manifestDir: string): { errors: string[]; declared: Set<string> } {
|
|
const errors: string[] = [];
|
|
const declared = new Set<string>();
|
|
const root = path.resolve(manifestDir);
|
|
for (const [index, file] of declaredAssetFiles(manifest).entries()) {
|
|
const location = `manifest.assetFiles[${index}]`;
|
|
if (typeof file.path !== "string" || !isSafeRelativePathRef(file.path)) {
|
|
errors.push(`${location}.path: unsafe file reference`);
|
|
continue;
|
|
}
|
|
if (declared.has(file.path)) {
|
|
errors.push(`${location}.path: duplicate asset file`);
|
|
continue;
|
|
}
|
|
declared.add(file.path);
|
|
if (file.mode !== undefined && file.mode !== 0o600 && file.mode !== 0o700) {
|
|
errors.push(`${location}.mode: unsafe file mode`);
|
|
}
|
|
const target = path.resolve(manifestDir, file.path);
|
|
const relative = path.relative(root, target);
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
errors.push(`${location}.path: file escapes plugin directory`);
|
|
continue;
|
|
}
|
|
if (!fs.existsSync(target)) {
|
|
errors.push(`${location}.path: missing file ${file.path}`);
|
|
continue;
|
|
}
|
|
const stat = fs.statSync(target);
|
|
if (!stat.isFile() || stat.size > 64 * 1024) {
|
|
errors.push(`${location}.path: asset file must be a regular file under 64KiB`);
|
|
continue;
|
|
}
|
|
const body = fs.readFileSync(target);
|
|
if (body.includes(0)) {
|
|
errors.push(`${location}.path: asset file contains NUL bytes`);
|
|
}
|
|
}
|
|
return { errors, declared };
|
|
}
|
|
|
|
function validateDependencyPlans(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
const plans = (manifest as { runtimeProfiles?: { installPlans?: Array<{ key?: string; steps?: Array<{ type?: string; downloadRef?: string }> }> } }).runtimeProfiles?.installPlans ?? [];
|
|
const errors: string[] = [];
|
|
for (const [planIndex, plan] of plans.entries()) {
|
|
for (const [stepIndex, step] of (plan.steps ?? []).entries()) {
|
|
if (step.type !== "verified-download" || !step.downloadRef) {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = new URL(step.downloadRef);
|
|
const host = parsed.hostname.toLowerCase();
|
|
const privateIPv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
|
|
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash || host === "localhost" || host.endsWith(".localhost") || privateIPv4 || host === "::1") {
|
|
errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: host is not approved for dependency download`);
|
|
}
|
|
} catch {
|
|
errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: URL is invalid`);
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateServerDeploymentProfiles(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return [];
|
|
const record = manifest as { server?: { createFields?: Array<{ key?: string }> }; runtimeProfiles?: { serverDeployments?: Array<any> } };
|
|
const declaredFields = new Set((record.server?.createFields ?? []).map((field) => field.key).filter((key): key is string => Boolean(key)));
|
|
const errors: string[] = [];
|
|
for (const [index, profile] of (record.runtimeProfiles?.serverDeployments ?? []).entries()) {
|
|
const location = `manifest.runtimeProfiles.serverDeployments[${index}]`;
|
|
const mappingKeys = new Set<string>();
|
|
for (const [mappingIndex, mapping] of (profile.configMappings ?? []).entries()) {
|
|
const mappingLocation = `${location}.configMappings[${mappingIndex}]`;
|
|
if (!declaredFields.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: must reference a declared server.createFields key`);
|
|
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
|
|
mappingKeys.add(mapping.fieldKey);
|
|
}
|
|
const requiredChecks = new Set(["executable.present", "port.bound", "config.readable", "process.healthy"]);
|
|
for (const check of profile.verificationChecks ?? []) {
|
|
if (check.required) requiredChecks.delete(check.kind);
|
|
}
|
|
for (const missing of requiredChecks) errors.push(`${location}.verificationChecks: required check ${missing} is missing`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateClientManagerProfiles(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
type ClientManagerProfile = {
|
|
key?: string;
|
|
version?: string;
|
|
repository?: { revisionPolicy?: string; branch?: string; tag?: string; revision?: string };
|
|
outputArtifacts?: string[];
|
|
deployment?: { executableRef?: string; requiredRunCapabilities?: string[] };
|
|
lifecycle?: { actions?: string[]; startupTimeoutSeconds?: number; stopTimeoutSeconds?: number };
|
|
health?: { mode?: string; intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number; requiredCapabilities?: string[] };
|
|
compatibility?: { minimumVersion?: string; maximumVersion?: string };
|
|
updatePolicy?: { healthConfirmationSeconds?: number };
|
|
};
|
|
const profiles = (manifest as { runtimeProfiles?: { clientManagers?: ClientManagerProfile[] } }).runtimeProfiles?.clientManagers ?? [];
|
|
const errors: string[] = [];
|
|
const parseVersion = (value: string | undefined): number[] | undefined => {
|
|
const match = value?.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/);
|
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
|
};
|
|
const compareVersions = (left: number[], right: number[]): number => {
|
|
for (let index = 0; index < 3; index += 1) {
|
|
if (left[index] !== right[index]) {
|
|
return left[index] - right[index];
|
|
}
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
for (const [profileIndex, profile] of profiles.entries()) {
|
|
const location = `manifest.runtimeProfiles.clientManagers[${profileIndex}]`;
|
|
const policy = profile.repository?.revisionPolicy;
|
|
if (policy === "pinned" && !profile.repository?.revision) {
|
|
errors.push(`${location}.repository.revision: required for pinned revision policy`);
|
|
}
|
|
if (policy === "branch" && !profile.repository?.branch) {
|
|
errors.push(`${location}.repository.branch: required for branch revision policy`);
|
|
}
|
|
if (policy === "tag" && !profile.repository?.tag) {
|
|
errors.push(`${location}.repository.tag: required for tag revision policy`);
|
|
}
|
|
if (!profile.deployment) {
|
|
continue;
|
|
}
|
|
if (!profile.version || !parseVersion(profile.version)) {
|
|
errors.push(`${location}.version: lifecycle deployment requires a semantic version`);
|
|
}
|
|
if (!profile.outputArtifacts?.includes(profile.deployment.executableRef ?? "")) {
|
|
errors.push(`${location}.deployment.executableRef: must name one declared output artifact`);
|
|
}
|
|
const actions = new Set(profile.lifecycle?.actions ?? []);
|
|
const runCapabilities = new Set(profile.deployment.requiredRunCapabilities ?? []);
|
|
if (!runCapabilities.has("client-manager.deploy")) {
|
|
errors.push(`${location}.deployment.requiredRunCapabilities: client-manager.deploy is required`);
|
|
}
|
|
if (["start", "stop", "restart", "status"].some((action) => actions.has(action)) && !runCapabilities.has("client-manager.control")) {
|
|
errors.push(`${location}.deployment.requiredRunCapabilities: lifecycle control actions require client-manager.control`);
|
|
}
|
|
if (actions.has("update") && !runCapabilities.has("client-manager.update")) {
|
|
errors.push(`${location}.deployment.requiredRunCapabilities: update requires client-manager.update`);
|
|
}
|
|
if (actions.has("rollback") && !runCapabilities.has("client-manager.rollback")) {
|
|
errors.push(`${location}.deployment.requiredRunCapabilities: rollback requires client-manager.rollback`);
|
|
}
|
|
if (actions.has("uninstall") && !runCapabilities.has("client-manager.uninstall")) {
|
|
errors.push(`${location}.deployment.requiredRunCapabilities: uninstall requires client-manager.uninstall`);
|
|
}
|
|
const interval = profile.health?.intervalSeconds ?? 0;
|
|
const degraded = profile.health?.degradedAfterSeconds ?? 0;
|
|
const offline = profile.health?.offlineAfterSeconds ?? 0;
|
|
if (degraded < interval * 2 || offline <= degraded) {
|
|
errors.push(`${location}.health: degraded threshold must allow two heartbeats and offline threshold must be later`);
|
|
}
|
|
if (profile.health?.mode === "component-heartbeat") {
|
|
const required = new Set(profile.health.requiredCapabilities ?? []);
|
|
for (const capability of ["component.register", "component.heartbeat", "component.health"]) {
|
|
if (!required.has(capability)) {
|
|
errors.push(`${location}.health.requiredCapabilities: ${capability} is required for component-heartbeat mode`);
|
|
}
|
|
}
|
|
}
|
|
const version = parseVersion(profile.version);
|
|
const minimum = parseVersion(profile.compatibility?.minimumVersion);
|
|
const maximum = parseVersion(profile.compatibility?.maximumVersion);
|
|
if (minimum && maximum && compareVersions(minimum, maximum) > 0) {
|
|
errors.push(`${location}.compatibility: minimumVersion must not exceed maximumVersion`);
|
|
}
|
|
if (version && minimum && compareVersions(version, minimum) < 0) {
|
|
errors.push(`${location}.compatibility: profile version is below minimumVersion`);
|
|
}
|
|
if (version && maximum && compareVersions(version, maximum) > 0) {
|
|
errors.push(`${location}.compatibility: profile version exceeds maximumVersion`);
|
|
}
|
|
if ((profile.updatePolicy?.healthConfirmationSeconds ?? 0) < interval) {
|
|
errors.push(`${location}.updatePolicy.healthConfirmationSeconds: must cover at least one health interval`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateDLLExtensionProfiles(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
type DLLExtensionProfile = {
|
|
key?: string;
|
|
kind?: string;
|
|
activation?: string;
|
|
releaseState?: string;
|
|
releaseUrl?: string;
|
|
checksum?: string;
|
|
sizeBytes?: number;
|
|
targetKey?: string;
|
|
modKey?: string;
|
|
dllRef?: string;
|
|
scumExecutableChecksum?: string;
|
|
ue4ssAbi?: string;
|
|
supportedTargets?: Array<{ os?: string; arch?: string }>;
|
|
updateOnStart?: boolean;
|
|
rconPort?: number;
|
|
};
|
|
type LifecycleProfile = { key?: string; mode?: string; capabilities?: string[]; dllExtensionRefs?: string[]; platforms?: string[] };
|
|
const runtimeProfiles = (manifest as { runtimeProfiles?: { dllExtensions?: DLLExtensionProfile[]; lifecycleProfiles?: LifecycleProfile[] } }).runtimeProfiles;
|
|
const profiles = runtimeProfiles?.dllExtensions ?? [];
|
|
const errors: string[] = [];
|
|
const checksumPattern = /^sha256:[a-fA-F0-9]{64}$/;
|
|
const privateIPv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/;
|
|
|
|
for (const [index, profile] of profiles.entries()) {
|
|
const location = `manifest.runtimeProfiles.dllExtensions[${index}]`;
|
|
if (profile.kind !== "ue4ss-dll" || profile.activation !== "server-start") {
|
|
errors.push(`${location}: only a server-start ue4ss-dll extension is allowed`);
|
|
}
|
|
if (profile.dllRef !== `ue4ss/Mods/${profile.modKey ?? ""}/dlls/main.dll`) {
|
|
errors.push(`${location}.dllRef: must be the declared UE4SS mod main.dll path`);
|
|
}
|
|
if (profile.updateOnStart !== true) {
|
|
errors.push(`${location}.updateOnStart: must be true for a managed DLL extension`);
|
|
}
|
|
if (!Number.isInteger(profile.rconPort) || (profile.rconPort ?? 0) < 1024 || (profile.rconPort ?? 0) > 65535) {
|
|
errors.push(`${location}.rconPort: must be an unprivileged TCP port`);
|
|
}
|
|
const target = profile.supportedTargets?.[0];
|
|
if (profile.supportedTargets?.length !== 1 || target?.os !== "windows" || target?.arch !== "amd64") {
|
|
errors.push(`${location}.supportedTargets: UE4SS DLL extensions support only windows/amd64`);
|
|
}
|
|
if (profile.releaseState !== "ready" && profile.releaseState !== "unpublished") {
|
|
errors.push(`${location}.releaseState: must be ready or unpublished`);
|
|
}
|
|
if (profile.releaseState === "ready") {
|
|
if (!checksumPattern.test(profile.checksum ?? "") || !checksumPattern.test(profile.scumExecutableChecksum ?? "")) {
|
|
errors.push(`${location}: release and SCUM executable SHA-256 checksums are required`);
|
|
}
|
|
if (!Number.isInteger(profile.sizeBytes) || (profile.sizeBytes ?? 0) < 1 || (profile.sizeBytes ?? 0) > 128 * 1024 * 1024) {
|
|
errors.push(`${location}.sizeBytes: must be a bounded DLL size`);
|
|
}
|
|
if (!profile.ue4ssAbi) {
|
|
errors.push(`${location}.ue4ssAbi: an ABI marker is required`);
|
|
}
|
|
}
|
|
if (profile.releaseUrl) {
|
|
try {
|
|
const parsed = new URL(profile.releaseUrl);
|
|
const host = parsed.hostname.toLowerCase();
|
|
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "::1" || privateIPv4.test(host) || !parsed.pathname.toLowerCase().endsWith(".dll")) {
|
|
errors.push(`${location}.releaseUrl: must be an approved public HTTPS DLL release`);
|
|
}
|
|
} catch {
|
|
errors.push(`${location}.releaseUrl: URL is invalid`);
|
|
}
|
|
} else if (profile.releaseState === "ready") {
|
|
errors.push(`${location}.releaseUrl: a ready release URL is required`);
|
|
}
|
|
}
|
|
|
|
const declared = new Set(profiles.map((profile) => profile.key).filter((key): key is string => Boolean(key)));
|
|
const stateByKey = new Map(profiles.map((profile) => [profile.key, profile.releaseState]));
|
|
for (const [index, profile] of (runtimeProfiles?.lifecycleProfiles ?? []).entries()) {
|
|
const extensionRefs = profile.dllExtensionRefs ?? [];
|
|
if (extensionRefs.length > 0 && (profile.mode !== "local-process" || !profile.capabilities?.includes("process.start") || profile.platforms?.length !== 1 || profile.platforms[0] !== "windows")) {
|
|
errors.push(`manifest.runtimeProfiles.lifecycleProfiles[${index}]: a DLL extension requires a windows local-process profile with process.start`);
|
|
}
|
|
for (const key of extensionRefs) {
|
|
if (!declared.has(key)) {
|
|
errors.push(`manifest.runtimeProfiles.lifecycleProfiles[${index}].dllExtensionRefs: undeclared DLL extension ${key}`);
|
|
}
|
|
if (stateByKey.get(key) !== "ready") {
|
|
errors.push(`manifest.runtimeProfiles.lifecycleProfiles[${index}].dllExtensionRefs: DLL extension ${key} is not ready for activation`);
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
type ProtectedRequest = { kind?: string; transportKey?: string; targetKey?: string; textField?: string; maxTextBytes?: number };
|
|
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string; protectedRequest?: ProtectedRequest };
|
|
type BridgeQueryTemplate = {
|
|
key?: string;
|
|
permission?: string;
|
|
engine?: string;
|
|
transportKey?: string;
|
|
targetKey?: string;
|
|
parameterSchemaRef?: string;
|
|
resultSchemaRef?: string;
|
|
maxRows?: number;
|
|
timeoutSeconds?: number;
|
|
};
|
|
type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean };
|
|
type BridgeOperationMutation = { fieldKey?: string; tableKey?: string; identityKey?: string; valueKey?: string; confirmationQueryKey?: string; allowedValueType?: string; minValue?: number; maxValue?: number };
|
|
type BridgeOperationTemplate = {
|
|
key?: string;
|
|
permission?: string;
|
|
approvalLevel?: string;
|
|
kind?: string;
|
|
transportKey?: string;
|
|
targetKey?: string;
|
|
payloadSchemaRef?: string;
|
|
resultSchemaRef?: string;
|
|
confirmationSchemaRef?: string;
|
|
timeoutSeconds?: number;
|
|
maxPayloadBytes?: number;
|
|
maxRowsAffected?: number;
|
|
mutation?: BridgeOperationMutation;
|
|
safety?: BridgeOperationSafety;
|
|
};
|
|
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] };
|
|
type BridgeCompanion = {
|
|
profileKey?: string;
|
|
configTemplateKey?: string;
|
|
configSchemaRef?: string;
|
|
heartbeatIntervalSeconds?: number;
|
|
commandPollIntervalSeconds?: number;
|
|
requestTimeoutSeconds?: number;
|
|
registrationProof?: string;
|
|
proofMaterialSource?: string;
|
|
proofMaterialEnv?: string;
|
|
sessionMode?: string;
|
|
tlsPolicy?: string;
|
|
};
|
|
type PluginPage = { key?: string; permissions?: string[]; bridgeActions?: string[] };
|
|
type RuntimeTransportProfile = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
|
|
type RuntimeClientManager = { key?: string; configTemplates?: Array<{ key?: string; outputRef?: string }>; health?: { intervalSeconds?: number; requiredCapabilities?: string[] } };
|
|
const declaration = manifest as {
|
|
capabilities?: string[];
|
|
permissions?: string[];
|
|
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
|
pages?: PluginPage[];
|
|
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
|
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
|
};
|
|
const bridge = declaration.gameClientBridge;
|
|
if (!bridge) {
|
|
return [];
|
|
}
|
|
const errors: string[] = [];
|
|
const commands = new Set<string>();
|
|
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
|
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
|
const operationTemplates = new Map<string, BridgeOperationTemplate>();
|
|
const declaredPermissions = new Set(declaration.permissions ?? []);
|
|
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
|
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
|
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
|
|
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
|
|
const companion = bridge.companion;
|
|
if (companion) {
|
|
const location = "manifest.gameClientBridge.companion";
|
|
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
|
if (!manager) {
|
|
errors.push(`${location}.profileKey: must reference a declared Client Manager profile`);
|
|
} else {
|
|
const template = manager.configTemplates?.find((candidate) => candidate.key === companion.configTemplateKey);
|
|
if (!template) {
|
|
errors.push(`${location}.configTemplateKey: must reference the Client Manager profile`);
|
|
} else if (template.outputRef !== "config.yaml") {
|
|
errors.push(`${location}.configTemplateKey: config template must materialize config.yaml`);
|
|
}
|
|
if (manager.health?.intervalSeconds !== companion.heartbeatIntervalSeconds) {
|
|
errors.push(`${location}.heartbeatIntervalSeconds: must match the Client Manager health interval`);
|
|
}
|
|
for (const capability of ["component.register", "component.heartbeat", "component.health", "game-client.bridge"]) {
|
|
if (!manager.health?.requiredCapabilities?.includes(capability)) {
|
|
errors.push(`${location}: Client Manager health must require ${capability}`);
|
|
}
|
|
}
|
|
}
|
|
if (!companion.configSchemaRef || !isSafeRelativeJsonRef(companion.configSchemaRef)) {
|
|
errors.push(`${location}.configSchemaRef: raw host paths and unsafe schema references are not allowed`);
|
|
}
|
|
if (companion.registrationProof !== "hmac-sha256" || companion.proofMaterialSource !== "component-package" || companion.sessionMode !== "component-session" || companion.tlsPolicy !== "verify-system-roots") {
|
|
errors.push(`${location}: secure component registration/session/TLS policy is required`);
|
|
}
|
|
const reservedProofEnvironments = new Set(["COMSPEC", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "HOME", "LD_LIBRARY_PATH", "LD_PRELOAD", "PATH", "PATHEXT", "SHELL", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR"]);
|
|
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(companion.proofMaterialEnv ?? "") || reservedProofEnvironments.has(companion.proofMaterialEnv ?? "")) {
|
|
errors.push(`${location}.proofMaterialEnv: must be a bounded environment variable name`);
|
|
}
|
|
if (!Number.isInteger(companion.commandPollIntervalSeconds) || (companion.commandPollIntervalSeconds ?? 0) < 1 || (companion.commandPollIntervalSeconds ?? 0) > 60) {
|
|
errors.push(`${location}.commandPollIntervalSeconds: must be between 1 and 60`);
|
|
}
|
|
if (!Number.isInteger(companion.requestTimeoutSeconds) || (companion.requestTimeoutSeconds ?? 0) < 1 || (companion.requestTimeoutSeconds ?? 0) > 60) {
|
|
errors.push(`${location}.requestTimeoutSeconds: must be between 1 and 60`);
|
|
}
|
|
}
|
|
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
|
const location = `manifest.gameClientBridge.commands[${index}]`;
|
|
const type = command.type ?? "";
|
|
const unsafeTypeReason = command.protectedRequest ? undefined : unsafeGameClientBridgeCommandTypeReason(type);
|
|
if (unsafeTypeReason) {
|
|
errors.push(`${location}.type: ${unsafeTypeReason}`);
|
|
}
|
|
if (!command.approvalLevel) {
|
|
errors.push(`${location}.approvalLevel: approval metadata is required`);
|
|
}
|
|
|
|
const protectedRequest = command.protectedRequest;
|
|
if (protectedRequest) {
|
|
if (!new Set(["sql", "rcon", "program"]).has(protectedRequest.kind ?? "")) {
|
|
errors.push(`${location}.protectedRequest.kind: must be sql, rcon, or program`);
|
|
}
|
|
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(protectedRequest.textField ?? "")) {
|
|
errors.push(`${location}.protectedRequest.textField: must be a safe bounded field name`);
|
|
}
|
|
if (!Number.isInteger(protectedRequest.maxTextBytes) || (protectedRequest.maxTextBytes ?? 0) < 1 || (protectedRequest.maxTextBytes ?? 0) > 16384) {
|
|
errors.push(`${location}.protectedRequest.maxTextBytes: must be between 1 and 16384`);
|
|
}
|
|
const transport = transportProfiles.find((candidate) => candidate.key === protectedRequest.transportKey);
|
|
if (!transport) {
|
|
errors.push(`${location}.protectedRequest.transportKey: must reference a declared runtime transport profile`);
|
|
} else {
|
|
if (!protectedRequest.targetKey || protectedRequest.targetKey !== transport.targetKey) {
|
|
errors.push(`${location}.protectedRequest.targetKey: must match the declared runtime transport target`);
|
|
}
|
|
const expectedCapability = { sql: "remote.run.protected.sql", rcon: "remote.run.protected.rcon", program: "remote.run.program.command" }[protectedRequest.kind ?? ""];
|
|
if (protectedRequest.kind === "sql" && transport.kind !== "mysql" && transport.kind !== "sqlite") {
|
|
errors.push(`${location}.protectedRequest.transportKey: sql requests require mysql or sqlite transport`);
|
|
}
|
|
if ((protectedRequest.kind === "rcon" && transport.kind !== "rcon") || (protectedRequest.kind === "program" && transport.kind !== "program")) {
|
|
errors.push(`${location}.protectedRequest.transportKey: transport kind does not match protected request kind`);
|
|
}
|
|
if (expectedCapability && !transport.capabilities?.includes(expectedCapability)) {
|
|
errors.push(`${location}.protectedRequest.transportKey: is missing required protected transport capability`);
|
|
}
|
|
}
|
|
}
|
|
for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) {
|
|
if (ref && !isSafeRelativeJsonRef(ref)) {
|
|
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
|
}
|
|
}
|
|
commands.add(type);
|
|
}
|
|
for (const [index, queryTemplate] of (bridge.queryTemplates ?? []).entries()) {
|
|
const location = `manifest.gameClientBridge.queryTemplates[${index}]`;
|
|
const key = queryTemplate.key ?? "";
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) {
|
|
errors.push(`${location}.key: query template key is unsafe`);
|
|
}
|
|
if (queryTemplates.has(key)) {
|
|
errors.push(`${location}.key: duplicate query template ${key}`);
|
|
}
|
|
queryTemplates.set(key, queryTemplate);
|
|
if (!queryTemplate.permission || !declaredPermissions.has(queryTemplate.permission)) {
|
|
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
|
}
|
|
if (queryTemplate.engine !== "sqlite") {
|
|
errors.push(`${location}.engine: only sqlite read-only query templates are allowed`);
|
|
}
|
|
for (const [field, ref] of [["parameterSchemaRef", queryTemplate.parameterSchemaRef], ["resultSchemaRef", queryTemplate.resultSchemaRef]] as const) {
|
|
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
|
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
|
}
|
|
}
|
|
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
|
|
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
|
|
}
|
|
if (!Number.isInteger(queryTemplate.timeoutSeconds) || (queryTemplate.timeoutSeconds ?? 0) < 1 || (queryTemplate.timeoutSeconds ?? 0) > 60) {
|
|
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 60`);
|
|
}
|
|
const transportProfile = transportProfiles.find((profile) => profile.key === queryTemplate.transportKey);
|
|
if (!transportProfile) {
|
|
errors.push(`${location}.transportKey: undeclared transport profile ${queryTemplate.transportKey ?? ""}`);
|
|
continue;
|
|
}
|
|
if (transportProfile.kind !== "sqlite") {
|
|
errors.push(`${location}.transportKey: transport profile must use sqlite`);
|
|
}
|
|
if (!queryTemplate.targetKey || transportProfile.targetKey !== queryTemplate.targetKey) {
|
|
errors.push(`${location}.targetKey: must match the declared sqlite transport target`);
|
|
}
|
|
if (!transportProfile.capabilities?.includes("remote.run.db.sqlite.query")) {
|
|
errors.push(`${location}.transportKey: sqlite transport must declare remote.run.db.sqlite.query`);
|
|
}
|
|
if (!declaredCapabilities.has("remote.run.db.sqlite.query") || !remoteCapabilities.has("remote.run.db.sqlite.query") || !remoteDatabaseEngines.has("sqlite")) {
|
|
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
|
}
|
|
}
|
|
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
|
|
const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
|
|
const key = operationTemplate.key ?? "";
|
|
const unsafeReason = unsafeGameClientBridgeCommandTypeReason(key);
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key) || unsafeReason) {
|
|
errors.push(`${location}.key: ${unsafeReason ?? "operation template key is unsafe"}`);
|
|
}
|
|
if (operationTemplates.has(key)) {
|
|
errors.push(`${location}.key: duplicate operation template ${key}`);
|
|
}
|
|
operationTemplates.set(key, operationTemplate);
|
|
if (!operationTemplate.permission || !declaredPermissions.has(operationTemplate.permission)) {
|
|
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
|
}
|
|
if (!new Set(["operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) {
|
|
errors.push(`${location}.approvalLevel: must require operator or platform-admin approval`);
|
|
}
|
|
if (!new Set(["rcon", "sqlite-mutation"]).has(operationTemplate.kind ?? "")) {
|
|
errors.push(`${location}.kind: must be rcon or sqlite-mutation`);
|
|
}
|
|
for (const [field, ref] of [["payloadSchemaRef", operationTemplate.payloadSchemaRef], ["resultSchemaRef", operationTemplate.resultSchemaRef], ["confirmationSchemaRef", operationTemplate.confirmationSchemaRef]] as const) {
|
|
if ((field === "payloadSchemaRef" && !ref) || (ref && !isSafeRelativeJsonRef(ref))) {
|
|
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
|
}
|
|
}
|
|
if (!Number.isInteger(operationTemplate.timeoutSeconds) || (operationTemplate.timeoutSeconds ?? 0) < 1 || (operationTemplate.timeoutSeconds ?? 0) > 3600) {
|
|
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 3600`);
|
|
}
|
|
if (!Number.isInteger(operationTemplate.maxPayloadBytes) || (operationTemplate.maxPayloadBytes ?? 0) < 1 || (operationTemplate.maxPayloadBytes ?? 0) > 65536) {
|
|
errors.push(`${location}.maxPayloadBytes: must be an integer between 1 and 65536`);
|
|
}
|
|
const transport = transportProfiles.find((profile) => profile.key === operationTemplate.transportKey);
|
|
if (!transport) {
|
|
errors.push(`${location}.transportKey: undeclared transport profile ${operationTemplate.transportKey ?? ""}`);
|
|
continue;
|
|
}
|
|
if (!operationTemplate.targetKey || transport.targetKey !== operationTemplate.targetKey) {
|
|
errors.push(`${location}.targetKey: must match the declared runtime transport target`);
|
|
}
|
|
if (operationTemplate.kind === "rcon") {
|
|
if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.protected.rcon")) {
|
|
errors.push(`${location}.transportKey: rcon operations require remote.run.protected.rcon transport`);
|
|
}
|
|
if (operationTemplate.maxRowsAffected !== undefined) {
|
|
errors.push(`${location}.maxRowsAffected: only sqlite-mutation operations may declare affected row bounds`);
|
|
}
|
|
if (operationTemplate.mutation !== undefined) {
|
|
errors.push(`${location}.mutation: only sqlite-mutation operations may declare mutation metadata`);
|
|
}
|
|
}
|
|
if (operationTemplate.kind === "sqlite-mutation") {
|
|
if (transport.kind !== "sqlite" || !transport.capabilities?.includes("remote.run.protected.sql")) {
|
|
errors.push(`${location}.transportKey: sqlite-mutation operations require sqlite remote.run.protected.sql transport`);
|
|
}
|
|
if (operationTemplate.approvalLevel !== "platform-admin") {
|
|
errors.push(`${location}.approvalLevel: sqlite-mutation operations require platform-admin approval`);
|
|
}
|
|
if (!Number.isInteger(operationTemplate.maxRowsAffected) || (operationTemplate.maxRowsAffected ?? 0) < 1 || (operationTemplate.maxRowsAffected ?? 0) > 10) {
|
|
errors.push(`${location}.maxRowsAffected: must be an integer between 1 and 10`);
|
|
}
|
|
const safety = operationTemplate.safety;
|
|
if (!safety?.requiresBeforeValue || !safety.requiresConfirmation || (!safety.requiresOfflinePlayer && !safety.requiresMaintenanceWindow)) {
|
|
errors.push(`${location}.safety: sqlite-mutation operations require before value, confirmation, and offline or maintenance protection`);
|
|
}
|
|
const mutation = operationTemplate.mutation;
|
|
if (!mutation) {
|
|
errors.push(`${location}.mutation: sqlite-mutation operations require field/table/identity metadata`);
|
|
} else {
|
|
for (const field of ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey"] as const) {
|
|
const value = mutation[field] ?? "";
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value) || unsafeGameClientBridgePayloadKey(value)) {
|
|
errors.push(`${location}.mutation.${field}: must be a safe logical key`);
|
|
}
|
|
}
|
|
if (!new Set(["integer", "number", "string", "boolean"]).has(mutation.allowedValueType ?? "")) {
|
|
errors.push(`${location}.mutation.allowedValueType: must be integer, number, string, or boolean`);
|
|
}
|
|
if (mutation.minValue !== undefined && mutation.maxValue !== undefined && mutation.minValue > mutation.maxValue) {
|
|
errors.push(`${location}.mutation: minValue must not exceed maxValue`);
|
|
}
|
|
if (mutation.confirmationQueryKey && !queryTemplates.has(mutation.confirmationQueryKey)) {
|
|
errors.push(`${location}.mutation.confirmationQueryKey: must reference a declared query template`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
|
for (const commandType of page.commandTypes ?? []) {
|
|
if (!commands.has(commandType)) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].commandTypes: undeclared command ${commandType}`);
|
|
}
|
|
}
|
|
for (const snapshotType of page.snapshotTypes ?? []) {
|
|
if (!snapshots.has(snapshotType)) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].snapshotTypes: undeclared snapshot ${snapshotType}`);
|
|
}
|
|
}
|
|
for (const queryTemplateKey of page.queryTemplateKeys ?? []) {
|
|
const queryTemplate = queryTemplates.get(queryTemplateKey);
|
|
if (!queryTemplate) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: undeclared query template ${queryTemplateKey}`);
|
|
continue;
|
|
}
|
|
const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey);
|
|
if (!pluginPage?.permissions?.includes(queryTemplate.permission ?? "")) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare query template permission ${queryTemplate.permission ?? ""}`);
|
|
}
|
|
if (!pluginPage?.bridgeActions?.includes("remote.access.request")) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`);
|
|
}
|
|
}
|
|
for (const operationKey of page.operationKeys ?? []) {
|
|
const operationTemplate = operationTemplates.get(operationKey);
|
|
if (!operationTemplate) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: undeclared operation template ${operationKey}`);
|
|
continue;
|
|
}
|
|
const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey);
|
|
if (!pluginPage?.permissions?.includes(operationTemplate.permission ?? "")) {
|
|
errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: page must declare operation template permission ${operationTemplate.permission ?? ""}`);
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
type RuntimeLogSource = { key?: string; retentionDays?: number };
|
|
type RuntimeLogEvent = {
|
|
key?: string;
|
|
sourceKey?: string;
|
|
eventType?: string;
|
|
permission?: string;
|
|
schemaRef?: string;
|
|
retentionDays?: number;
|
|
severity?: string;
|
|
};
|
|
const declaration = manifest as {
|
|
permissions?: string[];
|
|
runtimeProfiles?: { logSources?: RuntimeLogSource[]; logEvents?: RuntimeLogEvent[] };
|
|
};
|
|
const logEvents = declaration.runtimeProfiles?.logEvents ?? [];
|
|
const logSources = new Map((declaration.runtimeProfiles?.logSources ?? []).map((source) => [source.key ?? "", source]));
|
|
const permissions = new Set(declaration.permissions ?? []);
|
|
const keys = new Set<string>();
|
|
const eventTypes = new Set<string>();
|
|
const errors: string[] = [];
|
|
|
|
for (const [index, event] of logEvents.entries()) {
|
|
const location = `manifest.runtimeProfiles.logEvents[${index}]`;
|
|
const key = event.key ?? "";
|
|
const eventType = event.eventType ?? "";
|
|
if (keys.has(key)) {
|
|
errors.push(`${location}.key: duplicate semantic log event key ${key}`);
|
|
}
|
|
keys.add(key);
|
|
if (eventTypes.has(eventType)) {
|
|
errors.push(`${location}.eventType: duplicate semantic log event type ${eventType}`);
|
|
}
|
|
eventTypes.add(eventType);
|
|
const unsafeTypeReason = unsafeSemanticLogEventTypeReason(eventType);
|
|
if (unsafeTypeReason) {
|
|
errors.push(`${location}.eventType: ${unsafeTypeReason}`);
|
|
}
|
|
const source = logSources.get(event.sourceKey ?? "");
|
|
if (!source) {
|
|
errors.push(`${location}.sourceKey: undeclared log source ${event.sourceKey ?? ""}`);
|
|
}
|
|
if (!event.permission || !permissions.has(event.permission)) {
|
|
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
|
}
|
|
if (!event.schemaRef || !isSafeRelativeJsonRef(event.schemaRef)) {
|
|
errors.push(`${location}.schemaRef: raw host paths and unsafe schema references are not allowed`);
|
|
}
|
|
if (!Number.isInteger(event.retentionDays) || (event.retentionDays ?? 0) < 1 || (event.retentionDays ?? 0) > 365) {
|
|
errors.push(`${location}.retentionDays: must be an integer between 1 and 365`);
|
|
}
|
|
if (source?.retentionDays && (event.retentionDays ?? 0) > source.retentionDays) {
|
|
errors.push(`${location}.retentionDays: must not exceed source retentionDays`);
|
|
}
|
|
if (!event.severity || !["info", "notice", "warning", "critical"].includes(event.severity)) {
|
|
errors.push(`${location}.severity: must be info, notice, warning, or critical`);
|
|
}
|
|
}
|
|
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 (!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 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`);
|
|
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`);
|
|
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`);
|
|
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;
|
|
};
|
|
|
|
function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeSchemaReference[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string };
|
|
type BridgeSnapshot = { schemaRef?: string };
|
|
type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string };
|
|
type BridgeOperationTemplate = { payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string };
|
|
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[] } }).gameClientBridge;
|
|
if (!bridge) {
|
|
return [];
|
|
}
|
|
const refs: GameClientBridgeSchemaReference[] = [];
|
|
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
|
if (command.payloadSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.commands[${index}].payloadSchemaRef`, ref: command.payloadSchemaRef });
|
|
}
|
|
if (command.resultSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.commands[${index}].resultSchemaRef`, ref: command.resultSchemaRef });
|
|
}
|
|
}
|
|
for (const [index, snapshot] of (bridge.snapshots ?? []).entries()) {
|
|
if (snapshot.schemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.snapshots[${index}].schemaRef`, ref: snapshot.schemaRef });
|
|
}
|
|
}
|
|
for (const [index, queryTemplate] of (bridge.queryTemplates ?? []).entries()) {
|
|
if (queryTemplate.parameterSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].parameterSchemaRef`, ref: queryTemplate.parameterSchemaRef });
|
|
}
|
|
if (queryTemplate.resultSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef });
|
|
}
|
|
}
|
|
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
|
|
if (operationTemplate.payloadSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].payloadSchemaRef`, ref: operationTemplate.payloadSchemaRef });
|
|
}
|
|
if (operationTemplate.resultSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].resultSchemaRef`, ref: operationTemplate.resultSchemaRef });
|
|
}
|
|
if (operationTemplate.confirmationSchemaRef) {
|
|
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].confirmationSchemaRef`, ref: operationTemplate.confirmationSchemaRef });
|
|
}
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
|
const errors: string[] = [];
|
|
for (const declaration of referencedGameClientBridgeSchemas(manifest)) {
|
|
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
|
errors.push(`${declaration.location}: raw host paths and unsafe schema references are not allowed`);
|
|
continue;
|
|
}
|
|
const schemaPath = path.resolve(manifestDir, declaration.ref);
|
|
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
|
errors.push(`${declaration.location}: missing bridge schema file ${declaration.ref}`);
|
|
continue;
|
|
}
|
|
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
|
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
|
errors.push(`${declaration.location}: bridge schema must remain inside the plugin manifest directory`);
|
|
continue;
|
|
}
|
|
let schema: unknown;
|
|
try {
|
|
schema = readJson(schemaPath);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "invalid JSON";
|
|
errors.push(`${declaration.location}: bridge schema is not valid JSON: ${message}`);
|
|
continue;
|
|
}
|
|
try {
|
|
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
|
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
|
errors.push(...formatErrors(`${declaration.location}.schema`, schemaAjv.errors));
|
|
} else {
|
|
schemaAjv.compile(schema as AnySchema);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
|
errors.push(`${declaration.location}: bridge schema is invalid: ${message}`);
|
|
}
|
|
errors.push(...scanUnsafeBridgeSchema(schema, `${declaration.location}.schema`));
|
|
errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`));
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateRuntimeLogEventSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
const logEvents = (manifest as { runtimeProfiles?: { logEvents?: Array<{ schemaRef?: string }> } }).runtimeProfiles?.logEvents ?? [];
|
|
const errors: string[] = [];
|
|
for (const [index, event] of logEvents.entries()) {
|
|
const location = `manifest.runtimeProfiles.logEvents[${index}].schemaRef`;
|
|
const ref = event.schemaRef;
|
|
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
|
errors.push(`${location}: raw host paths and unsafe schema references are not allowed`);
|
|
continue;
|
|
}
|
|
const schemaPath = path.resolve(manifestDir, ref);
|
|
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
|
errors.push(`${location}: missing semantic log event schema file ${ref}`);
|
|
continue;
|
|
}
|
|
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
|
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
|
errors.push(`${location}: semantic log event schema must remain inside the plugin manifest directory`);
|
|
continue;
|
|
}
|
|
let schema: unknown;
|
|
try {
|
|
schema = readJson(schemaPath);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "invalid JSON";
|
|
errors.push(`${location}: semantic log event schema is not valid JSON: ${message}`);
|
|
continue;
|
|
}
|
|
try {
|
|
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
|
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
|
errors.push(...formatErrors(`${location}.schema`, schemaAjv.errors));
|
|
} else {
|
|
schemaAjv.compile(schema as AnySchema);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
|
errors.push(`${location}: semantic log event schema is invalid: ${message}`);
|
|
}
|
|
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
|
errors.push(...validateBoundedSemanticLogSchema(schema, `${location}.schema`));
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
type CompanionConfigDeclaration = {
|
|
profileKey?: string;
|
|
configSchemaRef?: string;
|
|
registrationProof?: string;
|
|
proofMaterialEnv?: string;
|
|
sessionMode?: string;
|
|
tlsPolicy?: string;
|
|
heartbeatIntervalSeconds?: number;
|
|
commandPollIntervalSeconds?: number;
|
|
requestTimeoutSeconds?: number;
|
|
};
|
|
|
|
type CompanionRuntimeProfile = {
|
|
key?: string;
|
|
version?: string;
|
|
supportedTargets?: Array<{ os?: string; arch?: string }>;
|
|
health?: { requiredCapabilities?: string[] };
|
|
};
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
}
|
|
|
|
function sameStringSet(left: unknown, right: string[]): boolean {
|
|
if (!Array.isArray(left) || !left.every((item) => typeof item === "string") || left.length !== right.length) {
|
|
return false;
|
|
}
|
|
const leftSet = new Set(left);
|
|
const rightSet = new Set(right);
|
|
return leftSet.size === left.length && rightSet.size === right.length && [...leftSet].every((item) => rightSet.has(item));
|
|
}
|
|
|
|
function isSafeHTTPSBaseURL(value: unknown): boolean {
|
|
if (typeof value !== "string" || value.length === 0 || /\s/.test(value)) {
|
|
return false;
|
|
}
|
|
try {
|
|
const parsed = new URL(value);
|
|
return parsed.protocol === "https:" && parsed.hostname !== "" && parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "" && (parsed.pathname === "" || parsed.pathname === "/");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function validateGeneratedCompanionConfig(manifest: unknown, companion: CompanionConfigDeclaration, example: unknown, location: string): string[] {
|
|
const declaration = manifest as {
|
|
id?: string;
|
|
runtimeProfiles?: { clientManagers?: CompanionRuntimeProfile[] };
|
|
};
|
|
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
|
const root = recordValue(example);
|
|
const platform = recordValue(root?.platform);
|
|
const component = recordValue(root?.component);
|
|
const proof = recordValue(root?.proof);
|
|
const session = recordValue(root?.session);
|
|
const timing = recordValue(root?.timing);
|
|
const tls = recordValue(root?.tls);
|
|
const errors: string[] = [];
|
|
|
|
if (!isSafeHTTPSBaseURL(platform?.baseUrl)) {
|
|
errors.push(`${location}.example.platform.baseUrl: must be an HTTPS origin without userinfo, path, query, or fragment`);
|
|
}
|
|
if (component?.pluginId !== declaration.id) {
|
|
errors.push(`${location}.example.component.pluginId: must match the plugin manifest id`);
|
|
}
|
|
if (component?.profileKey !== companion.profileKey) {
|
|
errors.push(`${location}.example.component.profileKey: must match the companion profileKey`);
|
|
}
|
|
if (manager?.version && component?.version !== manager.version) {
|
|
errors.push(`${location}.example.component.version: must match the Client Manager profile version`);
|
|
}
|
|
if (manager && !manager.supportedTargets?.some((target) => target.os === component?.targetOs && target.arch === component?.targetArch)) {
|
|
errors.push(`${location}.example.component: targetOs and targetArch must match a supported Client Manager target`);
|
|
}
|
|
if (proof?.mode !== companion.registrationProof) {
|
|
errors.push(`${location}.example.proof.mode: must match the companion registrationProof`);
|
|
}
|
|
if (proof?.materialEnv !== companion.proofMaterialEnv) {
|
|
errors.push(`${location}.example.proof.materialEnv: must match the companion proofMaterialEnv`);
|
|
}
|
|
if (session?.mode !== companion.sessionMode) {
|
|
errors.push(`${location}.example.session.mode: must match the companion sessionMode`);
|
|
}
|
|
if (tls?.policy !== companion.tlsPolicy) {
|
|
errors.push(`${location}.example.tls.policy: must match the companion tlsPolicy`);
|
|
}
|
|
for (const [field, expected] of [
|
|
["heartbeatIntervalSeconds", companion.heartbeatIntervalSeconds],
|
|
["commandPollIntervalSeconds", companion.commandPollIntervalSeconds],
|
|
["requestTimeoutSeconds", companion.requestTimeoutSeconds]
|
|
] as const) {
|
|
if (timing?.[field] !== expected) {
|
|
errors.push(`${location}.example.timing.${field}: must match the companion declaration`);
|
|
}
|
|
}
|
|
if (!manager || !sameStringSet(root?.capabilities, manager.health?.requiredCapabilities ?? [])) {
|
|
errors.push(`${location}.example.capabilities: must exactly match the Client Manager requiredCapabilities`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateGameClientBridgeCompanionConfig(manifest: unknown, manifestDir: string): string[] {
|
|
if (typeof manifest !== "object" || manifest === null) {
|
|
return [];
|
|
}
|
|
const companion = (manifest as { gameClientBridge?: { companion?: CompanionConfigDeclaration } }).gameClientBridge?.companion;
|
|
if (!companion) {
|
|
return [];
|
|
}
|
|
const location = "manifest.gameClientBridge.companion.configSchemaRef";
|
|
const ref = companion.configSchemaRef ?? "";
|
|
if (!isSafeRelativeJsonRef(ref)) {
|
|
return [`${location}: raw host paths and unsafe schema references are not allowed`];
|
|
}
|
|
const schemaPath = path.resolve(manifestDir, ref);
|
|
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
|
return [`${location}: missing companion config schema file ${ref}`];
|
|
}
|
|
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
|
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
|
return [`${location}: companion config schema must remain inside the plugin manifest directory`];
|
|
}
|
|
const errors: string[] = [];
|
|
try {
|
|
const schema = readJson(schemaPath) as AnySchema;
|
|
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
|
if (!ajv.validateSchema(schema)) {
|
|
errors.push(...formatErrors(`${location}.schema`, ajv.errors));
|
|
return errors;
|
|
}
|
|
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
|
errors.push(...validateBoundedBridgeSchema(schema, `${location}.schema`));
|
|
const validate = ajv.compile(schema);
|
|
const examplePath = schemaPath.replace(/\.schema\.json$/, ".generated.example.json");
|
|
if (!fs.existsSync(examplePath)) {
|
|
errors.push(`${location}: missing generated companion config example`);
|
|
return errors;
|
|
}
|
|
const example = readJson(examplePath);
|
|
if (!validate(example)) {
|
|
errors.push(...formatErrors(`${location}.example`, validate.errors));
|
|
}
|
|
errors.push(...scanUnsafeValues(example, `${location}.example`));
|
|
errors.push(...validateGeneratedCompanionConfig(manifest, companion, example, location));
|
|
const serialized = `${JSON.stringify(schema)}\n${JSON.stringify(example)}`;
|
|
if (/\/api\/v1\/scum-clients\//i.test(serialized) || /InsecureSkipVerify/i.test(serialized) || /"(?:authKey|componentKey|credential|password|sessionToken|secret)"\s*:/i.test(serialized)) {
|
|
errors.push(`${location}: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material`);
|
|
}
|
|
} catch (error) {
|
|
errors.push(`${location}: companion config schema or example is invalid: ${error instanceof Error ? error.message : "invalid JSON"}`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function validateManifestFile(manifestPath: string): string[] {
|
|
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
|
|
const manifest = readJson(absoluteManifestPath);
|
|
const manifestDir = path.dirname(absoluteManifestPath);
|
|
|
|
const ajv = new Ajv2020({ allErrors: true });
|
|
const validateManifest = ajv.compile(readJson(manifestSchemaPath) as AnySchema);
|
|
const validateCreateForm = ajv.compile(readJson(createFormSchemaPath) as AnySchema);
|
|
|
|
const errors: string[] = [];
|
|
if (!validateManifest(manifest)) {
|
|
errors.push(...formatErrors("manifest", validateManifest.errors));
|
|
}
|
|
|
|
errors.push(...scanUnsafeValues(manifest, "manifest"));
|
|
errors.push(...validateCreateFieldDeclarations(manifest));
|
|
errors.push(...validateDependencyPlans(manifest));
|
|
errors.push(...validateServerDeploymentProfiles(manifest));
|
|
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));
|
|
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
|
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
|
errors.push(...assetValidation.errors);
|
|
|
|
for (const declaration of referencedLifecycleActions(manifest)) {
|
|
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
|
errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`);
|
|
continue;
|
|
}
|
|
if (!assetValidation.declared.has(declaration.ref)) {
|
|
errors.push(`lifecycleAction.${declaration.action}: action file must be declared in manifest.assetFiles`);
|
|
}
|
|
const actionPath = path.resolve(manifestDir, declaration.ref);
|
|
if (!fs.existsSync(actionPath)) {
|
|
errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`);
|
|
continue;
|
|
}
|
|
errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action));
|
|
const action = readJson(actionPath);
|
|
const executableKey = typeof action === "object" && action !== null ? (action as { executableKey?: unknown }).executableKey : undefined;
|
|
if (typeof executableKey === "string" && !assetValidation.declared.has(executableKey)) {
|
|
errors.push(`lifecycleAction.${declaration.action}.executableKey: ${executableKey} must be declared in manifest.assetFiles`);
|
|
}
|
|
}
|
|
|
|
if (typeof manifest === "object" && manifest !== null && "server" in manifest) {
|
|
const server = (manifest as { server?: { createFormSchema?: string } }).server;
|
|
if (server?.createFormSchema) {
|
|
if (!isSafeRelativeJsonRef(server.createFormSchema)) {
|
|
errors.push("createFormSchema: unsafe file reference");
|
|
return errors;
|
|
}
|
|
const createFormPath = path.resolve(manifestDir, server.createFormSchema);
|
|
if (!fs.existsSync(createFormPath)) {
|
|
errors.push(`createFormSchema: missing file ${server.createFormSchema}`);
|
|
} else {
|
|
const createForm = readJson(createFormPath);
|
|
if (!validateCreateForm(createForm)) {
|
|
errors.push(...formatErrors("createForm", validateCreateForm.errors));
|
|
}
|
|
errors.push(...scanUnsafeValues(createForm, "createForm"));
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
const target = process.argv[2];
|
|
if (!target) {
|
|
console.error("usage: npm run validate:manifest -- <manifest-path>");
|
|
process.exit(2);
|
|
}
|
|
|
|
const errors = validateManifestFile(target);
|
|
if (errors.length > 0) {
|
|
console.error(errors.join("\n"));
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`validated ${target}`);
|
|
}
|