Rebuild SCUM plugin-owned data flow

This commit is contained in:
npc0-hue
2026-08-18 07:01:17 +08:00
parent 302f1f64b7
commit 98bf944f4c
39 changed files with 1832 additions and 223 deletions
+101 -2
View File
@@ -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 ?? "";