Integrate SCUM real ops workflows
This commit is contained in:
@@ -167,6 +167,12 @@ function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefi
|
||||
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("");
|
||||
@@ -669,7 +675,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
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;
|
||||
@@ -692,7 +716,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): 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 };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -702,6 +726,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): 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 ?? []);
|
||||
@@ -839,6 +864,92 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
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)) {
|
||||
@@ -864,6 +975,17 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
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;
|
||||
}
|
||||
@@ -944,7 +1066,8 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS
|
||||
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;
|
||||
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 [];
|
||||
}
|
||||
@@ -970,6 +1093,17 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user