Remove legacy client-manager workflows
This commit is contained in:
@@ -412,102 +412,24 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateClientManagerProfiles(manifest: unknown): string[] {
|
||||
function validateUnsupportedLegacyClientManagerDeclarations(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 declaration = manifest as {
|
||||
runtimeProfiles?: { clientManagers?: unknown[]; lifecycleProfiles?: Array<{ clientManagerRef?: unknown }> };
|
||||
gameClientBridge?: { companion?: unknown };
|
||||
};
|
||||
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`);
|
||||
if (declaration.runtimeProfiles?.clientManagers !== undefined) {
|
||||
errors.push("manifest.runtimeProfiles.clientManagers: client-manager profiles are no longer supported");
|
||||
}
|
||||
if (declaration.gameClientBridge?.companion !== undefined) {
|
||||
errors.push("manifest.gameClientBridge.companion: client-manager companion declarations are no longer supported");
|
||||
}
|
||||
for (const [index, profile] of (declaration.runtimeProfiles?.lifecycleProfiles ?? []).entries()) {
|
||||
if (profile.clientManagerRef !== undefined) {
|
||||
errors.push(`manifest.runtimeProfiles.lifecycleProfiles[${index}].clientManagerRef: client-manager references are no longer supported`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
@@ -626,29 +548,15 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
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 };
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[] };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -663,45 +571,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
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 ?? "";
|
||||
@@ -927,160 +796,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str
|
||||
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);
|
||||
@@ -1099,11 +814,10 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...validateCreateFieldDeclarations(manifest));
|
||||
errors.push(...validateDependencyPlans(manifest));
|
||||
errors.push(...validateServerDeploymentProfiles(manifest));
|
||||
errors.push(...validateClientManagerProfiles(manifest));
|
||||
errors.push(...validateUnsupportedLegacyClientManagerDeclarations(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));
|
||||
|
||||
Reference in New Issue
Block a user