Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -17,6 +17,19 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined):
|
||||
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
function extractNamedCaptureNames(pattern: string): string[] {
|
||||
const captures: string[] = [];
|
||||
const capturePattern = /\(\?(?:P)?<([A-Za-z][A-Za-z0-9_]*)>/g;
|
||||
for (const match of pattern.matchAll(capturePattern)) {
|
||||
captures.push(match[1]);
|
||||
}
|
||||
return captures;
|
||||
}
|
||||
|
||||
function normalizeNamedCaptureSyntax(pattern: string): string {
|
||||
return pattern.replace(/\(\?P<([A-Za-z][A-Za-z0-9_]*)>/g, "(?<$1>");
|
||||
}
|
||||
|
||||
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")) {
|
||||
@@ -673,9 +686,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
parameterSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
sqlRef?: string;
|
||||
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string> };
|
||||
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string>; writeMode?: string };
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
pollIntervalSeconds?: number;
|
||||
};
|
||||
type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string>; fixedValues?: Record<string, string>; observedAtField?: string };
|
||||
type BridgeLogProjection = {
|
||||
key?: string;
|
||||
streamKeys?: string[];
|
||||
steps?: Array<{ pattern?: string }>;
|
||||
correlationFields?: string[];
|
||||
maxInterveningLines?: number;
|
||||
target?: BridgeLogProjectionTarget;
|
||||
presence?: {
|
||||
timestampField?: string;
|
||||
activeWindowSeconds?: number;
|
||||
activityTarget?: BridgeLogProjectionTarget;
|
||||
announcement?: { profileKey?: string; commandType?: string; textField?: string; newTextTemplate?: string; returningTextTemplate?: 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 };
|
||||
@@ -718,7 +747,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[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -728,6 +757,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 logProjections = new Set<string>();
|
||||
const operationTemplates = new Map<string, BridgeOperationTemplate>();
|
||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||
@@ -851,6 +881,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const mappings = target?.columnMappings;
|
||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([destination, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(destination) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) errors.push(`${location}.rowTarget.columnMappings: projected queries require safe field mappings`);
|
||||
if (mappings && Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => key in mappings)) errors.push(`${location}.rowTarget.upsertKeys: every upsert key must be declared in columnMappings`);
|
||||
if (!new Set(["merge", "replace"]).has(target?.writeMode ?? "")) errors.push(`${location}.rowTarget.writeMode: projected queries require merge or replace`);
|
||||
}
|
||||
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`);
|
||||
@@ -858,6 +889,9 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
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 ?? ""}`);
|
||||
@@ -876,6 +910,71 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||
}
|
||||
}
|
||||
const captureNamePattern = /^[A-Za-z][A-Za-z0-9_]{0,79}$/;
|
||||
const fieldNamePattern = /^[A-Za-z][A-Za-z0-9._-]{0,79}$/;
|
||||
const collectionPattern = /^[A-Za-z][A-Za-z0-9._-]{0,119}$/;
|
||||
const validateProjectionTarget = (location: string, target: BridgeLogProjectionTarget | undefined, captures: Set<string>): string[] => {
|
||||
const targetErrors: string[] = [];
|
||||
if (!target || !collectionPattern.test(target.collection ?? "")) targetErrors.push(`${location}.collection: must be a safe collection`);
|
||||
if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length < 1 || target.upsertKeys.length > 8 || !target.upsertKeys.every((key) => fieldNamePattern.test(key))) targetErrors.push(`${location}.upsertKeys: must contain 1 to 8 safe fields`);
|
||||
const mappings = target?.captureMappings;
|
||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length < 1 || Object.keys(mappings).length > 64) {
|
||||
targetErrors.push(`${location}.captureMappings: must contain 1 to 64 mappings`);
|
||||
} else {
|
||||
for (const [destination, capture] of Object.entries(mappings)) {
|
||||
if (!fieldNamePattern.test(destination) || typeof capture !== "string" || !captureNamePattern.test(capture)) targetErrors.push(`${location}.captureMappings: contains an invalid field or capture`);
|
||||
if (!captures.has(capture)) targetErrors.push(`${location}.captureMappings: references undeclared capture ${capture}`);
|
||||
}
|
||||
}
|
||||
const fixedValues = target?.fixedValues ?? {};
|
||||
if (Array.isArray(fixedValues) || Object.keys(fixedValues).length > 64 || !Object.entries(fixedValues).every(([destination, value]) => fieldNamePattern.test(destination) && typeof value === "string" && value.length <= 4096)) targetErrors.push(`${location}.fixedValues: contains an invalid field or value`);
|
||||
const declaredFields = new Set([...Object.keys(mappings ?? {}), ...Object.keys(fixedValues)]);
|
||||
if (Object.keys(mappings ?? {}).some((field) => Object.prototype.hasOwnProperty.call(fixedValues, field))) targetErrors.push(`${location}: a field cannot be declared by both captureMappings and fixedValues`);
|
||||
if (target?.observedAtField && (!fieldNamePattern.test(target.observedAtField) || declaredFields.has(target.observedAtField))) targetErrors.push(`${location}.observedAtField: must be a safe unique field`);
|
||||
if (target?.observedAtField) declaredFields.add(target.observedAtField);
|
||||
if (Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => declaredFields.has(key))) targetErrors.push(`${location}.upsertKeys: every key must be projected`);
|
||||
return targetErrors;
|
||||
};
|
||||
for (const [index, projection] of (bridge.logProjections ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.logProjections[${index}]`;
|
||||
const key = projection.key ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) errors.push(`${location}.key: log projection key is unsafe`);
|
||||
if (logProjections.has(key)) errors.push(`${location}.key: duplicate log projection ${key}`);
|
||||
logProjections.add(key);
|
||||
if (!Array.isArray(projection.streamKeys) || projection.streamKeys.length < 1 || projection.streamKeys.length > 64 || new Set(projection.streamKeys).size !== projection.streamKeys.length || !projection.streamKeys.every((streamKey) => /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(streamKey))) errors.push(`${location}.streamKeys: must contain 1 to 64 unique safe streams`);
|
||||
const captures = new Set<string>();
|
||||
if (!Array.isArray(projection.steps) || projection.steps.length < 1 || projection.steps.length > 64) {
|
||||
errors.push(`${location}.steps: must contain 1 to 64 regular expressions`);
|
||||
} else {
|
||||
for (const [stepIndex, step] of projection.steps.entries()) {
|
||||
try {
|
||||
if (!step.pattern || step.pattern.length > 16384) throw new Error("invalid");
|
||||
new RegExp(normalizeNamedCaptureSyntax(step.pattern));
|
||||
for (const capture of extractNamedCaptureNames(step.pattern)) captures.add(capture);
|
||||
} catch {
|
||||
errors.push(`${location}.steps[${stepIndex}].pattern: must be a valid bounded regular expression`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(projection.correlationFields) || projection.correlationFields.length < 1 || projection.correlationFields.length > 64 || new Set(projection.correlationFields).size !== projection.correlationFields.length || !projection.correlationFields.every((field) => captureNamePattern.test(field) && captures.has(field))) errors.push(`${location}.correlationFields: must reference unique named captures`);
|
||||
if (!Number.isInteger(projection.maxInterveningLines) || (projection.maxInterveningLines ?? -1) < 0 || (projection.maxInterveningLines ?? 0) > 100000) errors.push(`${location}.maxInterveningLines: must be between 0 and 100000`);
|
||||
errors.push(...validateProjectionTarget(`${location}.target`, projection.target, captures));
|
||||
const presence = projection.presence;
|
||||
if (!presence) continue;
|
||||
const target = projection.target;
|
||||
const targetFields = new Set([...Object.keys(target?.captureMappings ?? {}), ...Object.keys(target?.fixedValues ?? {}), ...(target?.observedAtField ? [target.observedAtField] : [])]);
|
||||
if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`);
|
||||
if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`);
|
||||
if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures));
|
||||
const announcement = presence.announcement;
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === announcement?.profileKey && candidate.health?.requiredCapabilities?.includes("game-client.bridge"));
|
||||
if (!manager) errors.push(`${location}.presence.announcement.profileKey: must reference a declared game-client bridge profile`);
|
||||
const command = (bridge.commands ?? []).find((candidate) => candidate.type === announcement?.commandType);
|
||||
if (!command) errors.push(`${location}.presence.announcement.commandType: must reference a declared command`);
|
||||
if (!fieldNamePattern.test(announcement?.textField ?? "") || (command?.protectedRequest && command.protectedRequest.textField !== announcement?.textField)) errors.push(`${location}.presence.announcement.textField: must be safe and match the command protected request`);
|
||||
if (!announcement?.newTextTemplate || announcement.newTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.newTextTemplate: must be a non-empty bounded template`);
|
||||
if (!announcement?.returningTextTemplate || announcement.returningTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.returningTextTemplate: must be a non-empty bounded template`);
|
||||
}
|
||||
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
|
||||
const key = operationTemplate.key ?? "";
|
||||
|
||||
Reference in New Issue
Block a user