Restore durable log ingest and typed plugin projections
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ export const scumMigrationParityFixtures = {
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
|
||||
},
|
||||
trajectory: {
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "legacy-log" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -26,15 +26,13 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeLogProjectionDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
type RuntimeLogEventDeclaration,
|
||||
type RuntimeClientManagerProfile,
|
||||
type PluginLifecycleActionDeclaration,
|
||||
type PluginBridgeContext
|
||||
} from "../sdk/index.js";
|
||||
import { validateGameClientBridgeCatalog, validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
|
||||
const pluginsRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
@@ -131,33 +129,6 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
|
||||
}
|
||||
}
|
||||
|
||||
type MutableLogEventManifest = {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<Record<string, unknown>>;
|
||||
logEvents?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
function validateTemporaryLogEventManifest(mutate?: (manifest: MutableLogEventManifest, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-log-event-manifest-"));
|
||||
try {
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableLogEventManifest;
|
||||
manifest.runtimeProfiles = {
|
||||
logSources: [{ key: "server-events", kind: "file.tail", targetKey: "logs/server", streamKey: "game.server", cursorKind: "fingerprint", retentionDays: 30 }],
|
||||
logEvents: [{ key: "player-login", title: "Player login", sourceKey: "server-events", eventType: "game.login", permission: "server.logs.read", schemaRef: "schemas/log-events/login.event.schema.json", retentionDays: 30, severity: "info" }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ occurredAt: { type: "string", minLength: 1, maxLength: 40 }, playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["occurredAt", "playerId"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemporaryScumCompanionManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-manifest-"));
|
||||
try {
|
||||
@@ -190,24 +161,13 @@ describe("plugin manifest validation", () => {
|
||||
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("declares BattlEye login projection and presence deduplication", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any;
|
||||
const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login");
|
||||
expect(projection).toMatchObject({
|
||||
streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8,
|
||||
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" },
|
||||
presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" } }
|
||||
});
|
||||
expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([
|
||||
'Player "(?P<displayName>[^\"]+)" reported as player (?P<slot>\\d+)',
|
||||
"Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)"
|
||||
]);
|
||||
const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<"));
|
||||
expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" });
|
||||
expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" });
|
||||
const loginLogProjection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.login-log.login");
|
||||
expect(loginLogProjection.target.captureMappings).toMatchObject({ steamId: "steamId", displayName: "displayName", lastLoginIp: "ip" });
|
||||
expect(loginLogProjection.presence.activityTarget.captureMappings).toMatchObject({ steamId: "steamId", displayName: "displayName", lastLoginIp: "ip" });
|
||||
it("declares SCUM user projection through SQLite", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { queryTemplates: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: Array<{ collection: string; fixedValues?: Record<string, string> }> }> };
|
||||
};
|
||||
const users = manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.player.profile");
|
||||
expect(users).toMatchObject({ engine: "sqlite", transportKey: "scum-database", targetKey: "scum-database" });
|
||||
expect(users?.projections).toEqual(expect.arrayContaining([expect.objectContaining({ collection: "scum_users", fixedValues: { source: "run.sqlite.scum.player.profile" } })]));
|
||||
});
|
||||
|
||||
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
||||
@@ -309,7 +269,6 @@ describe("plugin manifest validation", () => {
|
||||
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
|
||||
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
|
||||
expect(example).toMatchObject({ trajectory: { enabled: true, source: "scum-sqlite", store: "shared-platform-mysql", fileEnv: "SCUM_DB_FILE", intervalSeconds: 3, maxRows: 500 } });
|
||||
});
|
||||
|
||||
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
|
||||
@@ -461,7 +420,6 @@ describe("plugin manifest validation", () => {
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
queryTemplates: Array<{ key: string; projections?: Array<{ collection?: string; fixedValues?: Record<string, string>; mergeExisting?: boolean }> }>;
|
||||
logProjections?: Array<{ key: string; streamKeys?: string[]; target?: { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string> } }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
@@ -488,7 +446,7 @@ describe("plugin manifest validation", () => {
|
||||
const serialized = JSON.stringify(manifest).toLowerCase();
|
||||
|
||||
expect(serialized).not.toContain("local-proof");
|
||||
expect(manifest.version).toBe("0.1.14");
|
||||
expect(manifest.version).toBe("0.1.15");
|
||||
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
|
||||
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
|
||||
@@ -502,10 +460,9 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.every((template) => !template.projections?.length)).toBe(true);
|
||||
expect(manifest.gameClientBridge.logProjections?.map((projection) => projection.key)).toEqual(expect.arrayContaining(["scum.trade.catalog", "scum.trade.events"]));
|
||||
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.catalog")).toMatchObject({ streamKeys: ["scum.trade"], target: { collection: "scum_trade_goods", upsertKeys: ["code"], captureMappings: { code: "itemCode" } } });
|
||||
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.events")?.target?.collection).toBe("scum_trade_events");
|
||||
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) })
|
||||
]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
@@ -594,7 +551,7 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("declares bounded SCUM snapshot schemas for operations views", () => {
|
||||
it("declares bounded SCUM snapshot schemas for operations projections", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
gameClientBridge: {
|
||||
@@ -672,7 +629,7 @@ describe("plugin manifest validation", () => {
|
||||
const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]);
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect([...templatesByKey.values()].every((template) => !template.projections?.length)).toBe(true);
|
||||
expect(templatesByKey.get("scum.player.profile")?.projections).toEqual([expect.objectContaining({ collection: "scum_users", rowPath: "rows", upsertKeys: ["steamId"], observedAtField: "profileSampledAt", mergeExisting: true })]);
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
|
||||
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||
@@ -682,6 +639,7 @@ describe("plugin manifest validation", () => {
|
||||
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
|
||||
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
|
||||
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
|
||||
expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database" })]));
|
||||
for (const key of expectedKeys) {
|
||||
const template = templatesByKey.get(key)!;
|
||||
expect(template.engine).toBe("sqlite");
|
||||
@@ -729,122 +687,26 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("packages SCUM v57 config, UTF-16LE logs, and gift metadata inside the plugin", () => {
|
||||
it("packages SCUM v57 config and gift metadata inside the plugin", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; dataRefs?: string[] }> };
|
||||
gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; configMapRefs: string[]; dataRefs?: string[] }> };
|
||||
};
|
||||
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57");
|
||||
expect(pack).toMatchObject({ databaseUserVersion: 57 });
|
||||
const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8"));
|
||||
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
|
||||
const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8"));
|
||||
const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8"));
|
||||
const storageModel = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![2]), "utf8"));
|
||||
expect(logParsers).toMatchObject({ encoding: "utf-16le", lineEnding: "lf", continuationPolicy: "append-to-previous-timestamped-record", timestampFormat: "yyyy.MM.dd-HH.mm.ss" });
|
||||
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "chat", "admin", "kill", "event-kill", "quests", "vehicle-destruction"]));
|
||||
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
||||
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
||||
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
||||
expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "plugin-shared-platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] });
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<{ key: string; kind?: string; streamKey?: string; retentionDays?: number }>;
|
||||
logEvents?: Array<RuntimeLogEventDeclaration>;
|
||||
};
|
||||
};
|
||||
const expectedTypes = ["scum.chat", "scum.login", "scum.logout", "scum.kill", "scum.trade", "scum.mine", "scum.unlock", "scum.admin", "scum.performance"];
|
||||
const logSources = new Map((manifest.runtimeProfiles?.logSources ?? []).map((source) => [source.key, source]));
|
||||
const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
|
||||
|
||||
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes));
|
||||
expect(logSources.get("scum-console-stdout")).toMatchObject({ kind: "process.stdout", streamKey: "scum.console.stdout" });
|
||||
expect(logSources.get("scum-console-stderr")).toMatchObject({ kind: "process.stderr", streamKey: "scum.console.stderr" });
|
||||
expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length);
|
||||
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
|
||||
for (const event of logEvents) {
|
||||
const source = logSources.get(event.sourceKey);
|
||||
expect(source).toBeDefined();
|
||||
expect(manifest.permissions).toContain(event.permission);
|
||||
expect(event.retentionDays).toBeGreaterThanOrEqual(1);
|
||||
expect(event.retentionDays).toBeLessThanOrEqual(source?.retentionDays ?? 365);
|
||||
expect(["info", "notice", "warning", "critical"]).toContain(event.severity);
|
||||
expect(event.schemaRef).toMatch(/^schemas\/log-events\/[a-z-]+\.event\.schema\.json$/);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, event.schemaRef), "utf8")) as Record<string, unknown>;
|
||||
expect((schema.properties as Record<string, Record<string, unknown>>).occurredAt).toMatchObject({ type: "string", format: "date-time" });
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
||||
expect(record.additionalProperties).toBe(false);
|
||||
}
|
||||
if (record.type === "array") {
|
||||
expect(record.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.minimum).toBeDefined();
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/);
|
||||
visit(schema);
|
||||
}
|
||||
const login = logEvents.find((event) => event.eventType === "scum.login");
|
||||
const loginSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, login?.schemaRef ?? ""), "utf8")) as { properties?: Record<string, Record<string, unknown>> };
|
||||
expect(loginSchema.properties?.networkFingerprint).toMatchObject({ type: "string", writeOnly: true });
|
||||
});
|
||||
|
||||
it("rejects unsafe semantic log declarations and missing references", () => {
|
||||
const errors = validateTemporaryLogEventManifest((manifest) => {
|
||||
const event = manifest.runtimeProfiles!.logEvents![0];
|
||||
event.eventType = "shell.execute";
|
||||
event.sourceKey = "missing-source";
|
||||
event.permission = "server.game-client.read";
|
||||
event.schemaRef = "schemas/log-events/missing.event.schema.json";
|
||||
event.retentionDays = 366;
|
||||
event.severity = "urgent";
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("eventType") && error.includes("not allowed"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("sourceKey") && error.includes("undeclared log source"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("permission") && error.includes("declared"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("missing semantic log event schema"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("retentionDays"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("severity"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded semantic log event schemas", () => {
|
||||
const errors = validateTemporaryLogEventManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, details: { type: "string" }, count: { type: "integer" } }, ["hostPath", "details", "count"]));
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("maxLength") && error.includes("bounded event strings"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("bounded event numbers"))).toBe(true);
|
||||
});
|
||||
|
||||
it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||
runtimeProfiles?: { clientManagers?: Array<{
|
||||
key: string;
|
||||
repository?: { url?: string; branch?: string };
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
@@ -852,8 +714,8 @@ describe("plugin manifest validation", () => {
|
||||
}> };
|
||||
};
|
||||
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
|
||||
expect(manager?.repository).toMatchObject({ url: "https://git.npc0.com/admin343/browser.git", branch: "main" });
|
||||
expect(manager?.build).toMatchObject({ workspaceRef: "plugins/examples/scum-server-plugin/companion", entryRef: "cmd/scum-companion" });
|
||||
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
|
||||
expect(manager?.build).not.toHaveProperty("workspaceRef");
|
||||
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
|
||||
expect(manager?.deployment?.arguments).toBeUndefined();
|
||||
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
|
||||
@@ -873,7 +735,6 @@ describe("plugin manifest validation", () => {
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600 } }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: []
|
||||
@@ -884,38 +745,6 @@ describe("plugin manifest validation", () => {
|
||||
expect(validate(manifest)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates ordered log projections and repeated correlation captures", () => {
|
||||
const projection = {
|
||||
key: "player.login",
|
||||
streamKeys: ["process.stdout"],
|
||||
steps: [
|
||||
{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" },
|
||||
{ pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }
|
||||
],
|
||||
correlationFields: ["slot"],
|
||||
maxInterveningLines: 16,
|
||||
target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" },
|
||||
presence: {
|
||||
timestampField: "lastLoginAt",
|
||||
activeWindowSeconds: 600,
|
||||
activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" }
|
||||
}
|
||||
};
|
||||
const manifest = {
|
||||
permissions: ["server.game-client.command"],
|
||||
runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] },
|
||||
gameClientBridge: {
|
||||
commands: [{ type: "diagnostic.ping", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json" }],
|
||||
snapshots: [],
|
||||
logProjections: [projection]
|
||||
}
|
||||
};
|
||||
expect(validateGameClientBridgeCatalog(manifest)).toEqual([]);
|
||||
|
||||
projection.target.captureMappings.steamId = "missing";
|
||||
const errors = validateGameClientBridgeCatalog(manifest);
|
||||
expect(errors.some((error) => error.includes("references undeclared capture missing"))).toBe(true);
|
||||
});
|
||||
|
||||
it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
@@ -1102,19 +931,6 @@ describe("plugin manifest validation", () => {
|
||||
|
||||
describe("plugin SDK", () => {
|
||||
|
||||
it("types generic runtime semantic log event declarations", () => {
|
||||
const declaration: RuntimeLogEventDeclaration = {
|
||||
key: "scum-performance",
|
||||
title: "SCUM server performance",
|
||||
sourceKey: "scum-performance-events",
|
||||
eventType: "scum.performance",
|
||||
permission: "server.logs.read",
|
||||
schemaRef: "schemas/log-events/performance.event.schema.json",
|
||||
retentionDays: 30,
|
||||
severity: "info"
|
||||
};
|
||||
expect(declaration).toMatchObject({ eventType: "scum.performance", permission: "server.logs.read", severity: "info" });
|
||||
});
|
||||
|
||||
it("types read-only SQLite query template declarations", () => {
|
||||
const declaration: GameClientBridgeQueryTemplateDeclaration = {
|
||||
@@ -1134,17 +950,6 @@ describe("plugin SDK", () => {
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("types plugin-declared ordered log projections", () => {
|
||||
const declaration: GameClientBridgeLogProjectionDeclaration = {
|
||||
key: "scum.player.login",
|
||||
streamKeys: ["process.stdout"],
|
||||
steps: [{ pattern: "Player (?<slot>\\d+) SteamID: (?<steamId>\\d+)" }],
|
||||
correlationFields: ["slot"],
|
||||
maxInterveningLines: 16,
|
||||
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "lastLoginAt" }
|
||||
};
|
||||
expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] });
|
||||
});
|
||||
|
||||
it("builds safe game-client bridge requests without component transport material", () => {
|
||||
const request = createGameClientBridgeQueueRequest({
|
||||
|
||||
@@ -39,8 +39,8 @@ const surfaceData: SCUMSurfaceData = {
|
||||
mapSettings: [],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
||||
trajectories: [
|
||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.scum.positions" },
|
||||
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.scum.vehicles" }
|
||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.positions" },
|
||||
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.vehicles" }
|
||||
],
|
||||
flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user