import fs from "node:fs"; 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 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 isSafeRelativeSqlRef(value: string): boolean { return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.sql$/i.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; 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; 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; } 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; runtimeProfiles?: { lifecycleProfiles?: Array<{ actionRefs?: Record }> }; }; const refs = new Map(); 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 } { const errors: string[] = []; const declared = new Set(); 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; } } 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 } }; 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(); 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 BridgeCommand = { type?: string; payloadSchemaRef?: string; resultSchemaRef?: string }; type BridgeQueryTemplate = { key?: string; permission?: string; engine?: string; transportKey?: string; targetKey?: string; parameterSchemaRef?: string; resultSchemaRef?: string; sqlRef?: string; maxRows?: number; timeoutSeconds?: number; pollIntervalSeconds?: number; }; type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: 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[]; pages?: BridgePage[]; companion?: BridgeCompanion }; }; const bridge = declaration.gameClientBridge; if (!bridge) { return []; } const errors: string[] = []; const commands = new Set(); const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? "")); const queryTemplates = new Map(); 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 = unsafeGameClientBridgeCommandTypeReason(type); if (unsafeTypeReason) { errors.push(`${location}.type: ${unsafeTypeReason}`); } 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 (queryTemplate.sqlRef !== undefined && !isSafeRelativeSqlRef(queryTemplate.sqlRef)) errors.push(`${location}.sqlRef: query templates require a package-relative SQL asset`); 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`); } if (!Number.isInteger(queryTemplate.pollIntervalSeconds ?? 0) || (queryTemplate.pollIntervalSeconds ?? 0) < 0 || (queryTemplate.pollIntervalSeconds ?? 0) > 86400) { errors.push(`${location}.pollIntervalSeconds: must be 0 or an integer between 1 and 86400`); } 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, 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`); } } } return errors; } function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set): string[] { if (typeof manifest !== "object" || manifest === null) return []; const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? []; const errors: string[] = []; const keys = new Set(); for (const [index, dataPack] of dataPacks.entries()) { const location = `manifest.gameClientBridge.dataPacks[${index}]`; if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique data-pack key`); keys.add(dataPack.key ?? ""); if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`); for (const field of ["configMapRefs", "dataRefs"] as const) { const refs = dataPack[field] ?? []; if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`); for (const ref of refs) { if (!isSafeRelativeJsonRef(ref)) { errors.push(`${location}.${field}: must use package-relative JSON assets`); continue; } if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`); const target = path.resolve(manifestDir, ref); if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package asset ${ref}`); } } } return errors; } function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set): string[] { if (typeof manifest !== "object" || manifest === null) return []; const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? []; const errors: string[] = []; for (const [index, template] of templates.entries()) { if (!template.sqlRef) continue; const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`; if (!isSafeRelativeSqlRef(template.sqlRef)) { errors.push(`${location}: must be a package-relative .sql asset`); continue; } if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`); const assetPath = path.resolve(manifestDir, template.sqlRef); if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) { errors.push(`${location}: missing SQL asset ${template.sqlRef}`); continue; } const body = fs.readFileSync(assetPath, "utf8").trim(); if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`); } 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 }; const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[] } }).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 }); } } 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; } 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 | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : 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(...validateGameClientBridgeSchemaFiles(manifest, manifestDir)); errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir)); const assetValidation = validateManifestAssetFiles(manifest, manifestDir); errors.push(...assetValidation.errors); errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared)); errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared)); 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 -- "); process.exit(2); } const errors = validateManifestFile(target); if (errors.length > 0) { console.error(errors.join("\n")); process.exit(1); } console.log(`validated ${target}`); }