功能修改
This commit is contained in:
@@ -38,10 +38,13 @@ describe("ai provider form schemas", () => {
|
||||
...emptyAiProviderForm(),
|
||||
id: "ai.openai",
|
||||
apiKeyRef: "",
|
||||
apiKeyConfigured: true
|
||||
apiKeyConfigured: true,
|
||||
baseUrl: "",
|
||||
baseUrlConfigured: true
|
||||
});
|
||||
|
||||
expect(request.apiKeyRef).toBe("");
|
||||
expect(request.baseUrl).toBe("");
|
||||
expect(JSON.stringify(request)).not.toContain("secret://providers/openai");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "./clientManagerLifecycle";
|
||||
|
||||
export const safeClientManagerLifecycleFixture = {
|
||||
id: "client-manager-installation-1",
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client-manager",
|
||||
targetOs: "windows",
|
||||
targetArch: "amd64",
|
||||
status: "online",
|
||||
phase: "component heartbeat healthy",
|
||||
desiredVersion: "2.0.0",
|
||||
activeVersion: "2.0.0",
|
||||
previousVersion: "1.0.0",
|
||||
desiredRevision: "rev-2",
|
||||
activeRevision: "rev-2",
|
||||
previousRevision: "rev-1",
|
||||
desiredArtifactId: "artifact-2",
|
||||
activeArtifactId: "artifact-2",
|
||||
previousArtifactId: "artifact-1",
|
||||
keyGeneration: 3,
|
||||
deploymentGeneration: 4,
|
||||
currentJobId: "job-update-1",
|
||||
lastSuccessfulJobId: "job-deploy-1",
|
||||
lastOperation: "update",
|
||||
health: "healthy",
|
||||
healthReason: "component heartbeat healthy",
|
||||
lastSeenAt: "2026-07-18T08:00:00Z",
|
||||
retryable: false,
|
||||
requiresRedeploy: false,
|
||||
updatedAt: "2026-07-18T08:00:00Z",
|
||||
distribution: { id: "distribution-2", artifactId: "artifact-2", sourceRevision: "rev-2", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 3, status: "available" },
|
||||
job: { id: "job-update-1", state: "running", progress: { percent: 65, message: "health confirmation" }, attempt: 1, createdAt: "2026-07-18T07:59:00Z", updatedAt: "2026-07-18T08:00:00Z" },
|
||||
actions: [
|
||||
{ operation: "start", available: false, reason: "already online" },
|
||||
{ operation: "stop", available: true },
|
||||
{ operation: "rollback", available: true }
|
||||
]
|
||||
} as const;
|
||||
|
||||
describe("Client Manager lifecycle schema", () => {
|
||||
it("preserves safe lifecycle, job progress, versions and action availability", () => {
|
||||
const parsed = parseSafeClientManagerLifecycle(safeClientManagerLifecycleFixture);
|
||||
expect(parsed).toMatchObject({ status: "online", health: "healthy", activeVersion: "2.0.0", previousVersion: "1.0.0", job: { state: "running", progress: { percent: 65 } } });
|
||||
expect(parseSafeClientManagerLifecycleList({ items: [safeClientManagerLifecycleFixture], count: 1 })).toMatchObject({ count: 1, items: [{ profileKey: "scum-client-manager" }] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ runEndpointId: "run-private" },
|
||||
{ pid: 4124 },
|
||||
{ secretRef: "redacted" },
|
||||
{ healthReason: "/Users/operator/client-manager" },
|
||||
{ healthReason: "unix://private.sock" }
|
||||
])("rejects machine and credential projection %#", (unsafe) => {
|
||||
expect(() => parseSafeClientManagerLifecycle({ ...safeClientManagerLifecycleFixture, ...unsafe })).toThrow(/forbidden|sensitive/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
ClientManagerInstallationListResponse,
|
||||
ClientManagerInstallationResponse,
|
||||
ClientManagerLifecycleActionResponse,
|
||||
ClientManagerLifecycleOperation,
|
||||
ClientManagerLifecycleStatus,
|
||||
JobState
|
||||
} from "../api/types";
|
||||
|
||||
const lifecycleStatuses = new Set<ClientManagerLifecycleStatus>([
|
||||
"requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed"
|
||||
]);
|
||||
const lifecycleOperations = new Set<ClientManagerLifecycleOperation>(["deploy", "start", "stop", "restart", "status", "update", "rollback", "uninstall"]);
|
||||
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
|
||||
const forbiddenKeys = new Set(["key", "token", "secretref", "secretvalue", "hostpath", "pid", "socket", "credential", "dsn", "password", "runendpointid"]);
|
||||
const forbiddenFragments = ["secret://", "/users/", "/var/run/", "bearer ", "password=", "unix://", "tcp://", "mysql://", "sqlite://", "rcon://"];
|
||||
|
||||
export function parseSafeClientManagerLifecycleList(value: unknown): ClientManagerInstallationListResponse {
|
||||
const record = object(value, "Client Manager lifecycle list");
|
||||
rejectSensitiveProjection(record);
|
||||
const items = array(record.items, "items").map(parseSafeClientManagerLifecycle);
|
||||
const count = number(record.count, "count");
|
||||
return { items, count };
|
||||
}
|
||||
|
||||
export function parseSafeClientManagerLifecycle(value: unknown): ClientManagerInstallationResponse {
|
||||
const record = object(value, "Client Manager lifecycle");
|
||||
rejectSensitiveProjection(record);
|
||||
const status = string(record.status, "status") as ClientManagerLifecycleStatus;
|
||||
if (!lifecycleStatuses.has(status)) throw new Error("Client Manager lifecycle status is invalid");
|
||||
const actions = array(record.actions, "actions").map(parseAction);
|
||||
const result: ClientManagerInstallationResponse = {
|
||||
id: string(record.id, "id"),
|
||||
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
|
||||
pluginId: string(record.pluginId, "pluginId"),
|
||||
profileKey: string(record.profileKey, "profileKey"),
|
||||
targetOs: string(record.targetOs, "targetOs"),
|
||||
targetArch: string(record.targetArch, "targetArch"),
|
||||
status,
|
||||
phase: string(record.phase, "phase"),
|
||||
keyGeneration: number(record.keyGeneration, "keyGeneration"),
|
||||
deploymentGeneration: number(record.deploymentGeneration, "deploymentGeneration"),
|
||||
health: health(record.health),
|
||||
retryable: boolean(record.retryable, "retryable"),
|
||||
requiresRedeploy: boolean(record.requiresRedeploy, "requiresRedeploy"),
|
||||
updatedAt: string(record.updatedAt, "updatedAt"),
|
||||
actions
|
||||
};
|
||||
copyOptionalStrings(record, result, ["desiredVersion", "activeVersion", "previousVersion", "desiredRevision", "activeRevision", "previousRevision", "desiredArtifactId", "activeArtifactId", "previousArtifactId", "currentJobId", "lastSuccessfulJobId", "healthReason", "lastSeenAt", "installedAt", "uninstalledAt"]);
|
||||
if (record.lastOperation !== undefined) {
|
||||
const operation = string(record.lastOperation, "lastOperation") as ClientManagerLifecycleOperation;
|
||||
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager lifecycle operation is invalid");
|
||||
result.lastOperation = operation;
|
||||
}
|
||||
if (record.distribution !== undefined) {
|
||||
const distribution = object(record.distribution, "distribution");
|
||||
result.distribution = {
|
||||
id: string(distribution.id, "distribution.id"), artifactId: string(distribution.artifactId, "distribution.artifactId"), sourceRevision: string(distribution.sourceRevision, "distribution.sourceRevision"),
|
||||
targetOs: string(distribution.targetOs, "distribution.targetOs"), targetArch: string(distribution.targetArch, "distribution.targetArch"), checksum: string(distribution.checksum, "distribution.checksum"),
|
||||
keyGeneration: number(distribution.keyGeneration, "distribution.keyGeneration"), status: string(distribution.status, "distribution.status")
|
||||
};
|
||||
}
|
||||
if (record.job !== undefined) {
|
||||
const job = object(record.job, "job");
|
||||
const state = string(job.state, "job.state") as JobState;
|
||||
if (!jobStates.has(state)) throw new Error("Client Manager job state is invalid");
|
||||
const progress = object(job.progress, "job.progress");
|
||||
result.job = { id: string(job.id, "job.id"), state, progress: { percent: number(progress.percent, "job.progress.percent"), message: optionalString(progress.message) }, attempt: number(job.attempt, "job.attempt"), createdAt: string(job.createdAt, "job.createdAt"), updatedAt: string(job.updatedAt, "job.updatedAt") };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseAction(value: unknown): ClientManagerLifecycleActionResponse {
|
||||
const action = object(value, "action");
|
||||
const operation = string(action.operation, "action.operation") as ClientManagerLifecycleOperation;
|
||||
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager action is invalid");
|
||||
return { operation, available: boolean(action.available, "action.available"), reason: optionalString(action.reason) };
|
||||
}
|
||||
|
||||
function rejectSensitiveProjection(value: unknown, key = ""): void {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.toLowerCase();
|
||||
if (forbiddenFragments.some((fragment) => normalized.includes(fragment))) throw new Error("Client Manager response contains sensitive machine data");
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => rejectSensitiveProjection(item, key));
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const [childKey, child] of Object.entries(value)) {
|
||||
if (forbiddenKeys.has(childKey.toLowerCase())) throw new Error("Client Manager response contains a forbidden field");
|
||||
rejectSensitiveProjection(child, childKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyOptionalStrings(source: Record<string, unknown>, target: ClientManagerInstallationResponse, keys: Array<keyof ClientManagerInstallationResponse>) {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "string" && value !== "") (target as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function array(value: unknown, label: string): unknown[] { if (!Array.isArray(value)) throw new Error(`${label} must be an array`); return value; }
|
||||
function string(value: unknown, label: string): string { if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`); return value; }
|
||||
function optionalString(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; }
|
||||
function number(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`); return value; }
|
||||
function boolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); return value; }
|
||||
function health(value: unknown): ClientManagerInstallationResponse["health"] {
|
||||
if (value === "unknown" || value === "healthy" || value === "degraded" || value === "unhealthy" || value === "offline") return value;
|
||||
throw new Error("Client Manager health is invalid");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseSafeGameClientBridgeCommand,
|
||||
parseSafeGameClientBridgeSnapshotList,
|
||||
parseSafeGameClientBridgeStatus
|
||||
} from "./gameClientBridge";
|
||||
|
||||
const now = "2026-07-20T08:00:00Z";
|
||||
|
||||
const safeCommand = {
|
||||
id: "command-1",
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client",
|
||||
commandType: "scum.player.lookup",
|
||||
priority: 10,
|
||||
state: "succeeded",
|
||||
approvalState: "not_required",
|
||||
requesterId: "user-1",
|
||||
result: { status: "succeeded", summary: "player found", payload: { found: true }, completedAt: now },
|
||||
auditReferences: ["audit-1"],
|
||||
expiresAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: now
|
||||
};
|
||||
|
||||
const safeSnapshotList = {
|
||||
items: [{
|
||||
id: "snapshot-1",
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client",
|
||||
type: "scum.players",
|
||||
schemaVersion: "1",
|
||||
streamKey: "current",
|
||||
sequence: 1,
|
||||
observedAt: now,
|
||||
payload: { players: [{ playerId: "player-1" }] },
|
||||
retention: { keepForSeconds: 3600, maxRecords: 24 },
|
||||
createdAt: now,
|
||||
expiresAt: now
|
||||
}],
|
||||
count: 1
|
||||
};
|
||||
|
||||
describe("Game Client Bridge safe projection schema", () => {
|
||||
it("preserves declarations, approval, result, retention and typed snapshot payloads", () => {
|
||||
expect(parseSafeGameClientBridgeStatus({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
available: true,
|
||||
profiles: [{ pluginId: "game.scum", profileKey: "scum-client", available: true, commandTypes: ["scum.player.lookup"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
})).toMatchObject({ available: true, profiles: [{ queryTemplateKeys: ["scum.player.search"] }] });
|
||||
expect(parseSafeGameClientBridgeCommand(safeCommand)).toMatchObject({ approvalState: "not_required", result: { payload: { found: true } } });
|
||||
expect(parseSafeGameClientBridgeSnapshotList(safeSnapshotList)).toMatchObject({ count: 1, items: [{ retention: { maxRecords: 24 } }] });
|
||||
expect(parseSafeGameClientBridgeSnapshotList({
|
||||
...safeSnapshotList,
|
||||
items: [{ ...safeSnapshotList.items[0], payload: { sessions: [{ sessionId: "game-session-1", playerId: "player-1" }] } }]
|
||||
})).toMatchObject({ items: [{ payload: { sessions: [{ sessionId: "game-session-1" }] } }] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sessionToken: "component-session-material" },
|
||||
{ componentSession: "component-session-material" },
|
||||
{ componentKey: "raw-component-key" },
|
||||
{ sourceSessionId: "component-session-1" },
|
||||
{ result: { ...safeCommand.result, payload: { dsn: "sqlite:///srv/scum/SCUM.db" } } },
|
||||
{ result: { ...safeCommand.result, payload: { endpoint: "tcp://127.0.0.1:9999" } } },
|
||||
{ result: { ...safeCommand.result, payload: { output: "/Users/operator/scum/config.yaml" } } }
|
||||
])("rejects forbidden command projection %#", (unsafe) => {
|
||||
expect(() => parseSafeGameClientBridgeCommand({ ...safeCommand, ...unsafe })).toThrow(/forbidden|sensitive/i);
|
||||
});
|
||||
|
||||
it("rejects credentials nested inside snapshot payloads", () => {
|
||||
const unsafe = structuredClone(safeSnapshotList);
|
||||
unsafe.items[0].payload = { players: [{ playerId: "player-1", credential: "raw-password" }] } as unknown as typeof unsafe.items[0]["payload"];
|
||||
expect(() => parseSafeGameClientBridgeSnapshotList(unsafe)).toThrow(/forbidden/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {
|
||||
GameClientBridgeApprovalState,
|
||||
GameClientBridgeCancelResponse,
|
||||
GameClientBridgeCommandCancellationResponse,
|
||||
GameClientBridgeCommandListResponse,
|
||||
GameClientBridgeCommandResponse,
|
||||
GameClientBridgeCommandResultResponse,
|
||||
GameClientBridgeCommandState,
|
||||
GameClientBridgeJsonObject,
|
||||
GameClientBridgeJsonValue,
|
||||
GameClientBridgeProfileDeclarationResponse,
|
||||
GameClientBridgeResultStatus,
|
||||
GameClientBridgeSnapshotListResponse,
|
||||
GameClientBridgeSnapshotResponse,
|
||||
GameClientBridgeStatusResponse
|
||||
} from "../api/types";
|
||||
|
||||
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired"]);
|
||||
const approvalStates = new Set<GameClientBridgeApprovalState>(["not_required", "pending", "approved", "rejected"]);
|
||||
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled"]);
|
||||
const forbiddenKeys = new Set([
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"accesskeyid",
|
||||
"claimlease",
|
||||
"componentkey",
|
||||
"componentsession",
|
||||
"componentsessionid",
|
||||
"credential",
|
||||
"credentials",
|
||||
"deploymentgeneration",
|
||||
"dsn",
|
||||
"fencingtoken",
|
||||
"hostpath",
|
||||
"installationid",
|
||||
"keygeneration",
|
||||
"leaseexpiresat",
|
||||
"password",
|
||||
"privatekey",
|
||||
"rawcredential",
|
||||
"runendpoint",
|
||||
"runendpointurl",
|
||||
"secret",
|
||||
"secretref",
|
||||
"secretvalue",
|
||||
"sessiontoken",
|
||||
"socket",
|
||||
"sourcesessionid",
|
||||
"storagecredential",
|
||||
"token"
|
||||
]);
|
||||
const forbiddenFragments = [
|
||||
"bearer ",
|
||||
"password=",
|
||||
"secret://",
|
||||
"unix://",
|
||||
"tcp://",
|
||||
"mysql://",
|
||||
"postgres://",
|
||||
"sqlite://",
|
||||
"rcon://"
|
||||
];
|
||||
const forbiddenHostPath = /(?:^|[\s"'])(?:\/[Uu]sers\/|\/home\/|\/root\/|\/var\/|\/etc\/|\/opt\/|[a-z]:[\\/]|\\\\[^\\]+\\)/;
|
||||
|
||||
export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridgeStatusResponse {
|
||||
const record = safeObject(value, "Game Client Bridge status");
|
||||
return {
|
||||
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
|
||||
pluginId: string(record.pluginId, "pluginId"),
|
||||
available: boolean(record.available, "available"),
|
||||
reason: optionalString(record.reason, "reason"),
|
||||
profiles: array(record.profiles, "profiles").map(parseProfile)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSafeGameClientBridgeCommand(value: unknown): GameClientBridgeCommandResponse {
|
||||
const record = safeObject(value, "Game Client Bridge command");
|
||||
const result: GameClientBridgeCommandResponse = {
|
||||
id: string(record.id, "id"),
|
||||
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
|
||||
pluginId: string(record.pluginId, "pluginId"),
|
||||
profileKey: string(record.profileKey, "profileKey"),
|
||||
commandType: string(record.commandType, "commandType"),
|
||||
priority: number(record.priority, "priority"),
|
||||
state: commandState(record.state),
|
||||
approvalState: approvalState(record.approvalState),
|
||||
expiresAt: string(record.expiresAt, "expiresAt"),
|
||||
createdAt: string(record.createdAt, "createdAt"),
|
||||
updatedAt: string(record.updatedAt, "updatedAt")
|
||||
};
|
||||
copyOptionalString(record, result, "requesterId");
|
||||
copyOptionalString(record, result, "resultSummary");
|
||||
copyOptionalString(record, result, "completedAt");
|
||||
const auditReferences = optionalStringArray(record.auditReferences, "auditReferences");
|
||||
if (auditReferences) result.auditReferences = auditReferences;
|
||||
if (record.result !== undefined) result.result = parseResult(record.result);
|
||||
if (record.cancellation !== undefined) result.cancellation = parseCancellation(record.cancellation);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSafeGameClientBridgeCommandList(value: unknown): GameClientBridgeCommandListResponse {
|
||||
const record = safeObject(value, "Game Client Bridge command list");
|
||||
return {
|
||||
items: array(record.items, "items").map(parseSafeGameClientBridgeCommand),
|
||||
count: nonNegativeInteger(record.count, "count")
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSafeGameClientBridgeCancellation(value: unknown): GameClientBridgeCancelResponse {
|
||||
const record = safeObject(value, "Game Client Bridge cancellation");
|
||||
const result: GameClientBridgeCancelResponse = {
|
||||
commandId: string(record.commandId, "commandId"),
|
||||
state: commandState(record.state),
|
||||
cancellation: parseCancellation(record.cancellation),
|
||||
updatedAt: string(record.updatedAt, "updatedAt")
|
||||
};
|
||||
const auditReferences = optionalStringArray(record.auditReferences, "auditReferences");
|
||||
if (auditReferences) result.auditReferences = auditReferences;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSafeGameClientBridgeSnapshotList(value: unknown): GameClientBridgeSnapshotListResponse {
|
||||
const record = safeObject(value, "Game Client Bridge snapshot list");
|
||||
return {
|
||||
items: array(record.items, "items").map(parseSnapshot),
|
||||
count: nonNegativeInteger(record.count, "count")
|
||||
};
|
||||
}
|
||||
|
||||
function parseProfile(value: unknown): GameClientBridgeProfileDeclarationResponse {
|
||||
const record = safeObject(value, "Game Client Bridge profile");
|
||||
return {
|
||||
pluginId: string(record.pluginId, "profile.pluginId"),
|
||||
profileKey: string(record.profileKey, "profile.profileKey"),
|
||||
available: boolean(record.available, "profile.available"),
|
||||
reason: optionalString(record.reason, "profile.reason"),
|
||||
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
|
||||
snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"),
|
||||
queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys")
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): GameClientBridgeCommandResultResponse {
|
||||
const record = safeObject(value, "Game Client Bridge command result");
|
||||
const result: GameClientBridgeCommandResultResponse = {
|
||||
status: resultStatus(record.status),
|
||||
completedAt: string(record.completedAt, "result.completedAt")
|
||||
};
|
||||
const summary = optionalString(record.summary, "result.summary");
|
||||
if (summary) result.summary = summary;
|
||||
if (record.payload !== undefined) result.payload = jsonObject(record.payload, "result.payload");
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseCancellation(value: unknown): GameClientBridgeCommandCancellationResponse {
|
||||
const record = safeObject(value, "Game Client Bridge command cancellation");
|
||||
const result: GameClientBridgeCommandCancellationResponse = {
|
||||
cancelledAt: string(record.cancelledAt, "cancellation.cancelledAt")
|
||||
};
|
||||
const requestedBy = optionalString(record.requestedBy, "cancellation.requestedBy");
|
||||
const reason = optionalString(record.reason, "cancellation.reason");
|
||||
if (requestedBy) result.requestedBy = requestedBy;
|
||||
if (reason) result.reason = reason;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown): GameClientBridgeSnapshotResponse {
|
||||
const record = safeObject(value, "Game Client Bridge snapshot");
|
||||
const retention = safeObject(record.retention, "snapshot.retention");
|
||||
const result: GameClientBridgeSnapshotResponse = {
|
||||
id: string(record.id, "snapshot.id"),
|
||||
serverInstanceId: string(record.serverInstanceId, "snapshot.serverInstanceId"),
|
||||
pluginId: string(record.pluginId, "snapshot.pluginId"),
|
||||
profileKey: string(record.profileKey, "snapshot.profileKey"),
|
||||
type: string(record.type, "snapshot.type"),
|
||||
schemaVersion: string(record.schemaVersion, "snapshot.schemaVersion"),
|
||||
streamKey: string(record.streamKey, "snapshot.streamKey"),
|
||||
sequence: nonNegativeInteger(record.sequence, "snapshot.sequence"),
|
||||
observedAt: string(record.observedAt, "snapshot.observedAt"),
|
||||
payload: jsonObject(record.payload, "snapshot.payload"),
|
||||
retention: {
|
||||
keepForSeconds: nonNegativeInteger(retention.keepForSeconds, "snapshot.retention.keepForSeconds")
|
||||
},
|
||||
createdAt: string(record.createdAt, "snapshot.createdAt"),
|
||||
expiresAt: string(record.expiresAt, "snapshot.expiresAt")
|
||||
};
|
||||
if (retention.maxRecords !== undefined) result.retention.maxRecords = nonNegativeInteger(retention.maxRecords, "snapshot.retention.maxRecords");
|
||||
const auditReferences = optionalStringArray(record.auditReferences, "snapshot.auditReferences");
|
||||
if (auditReferences) result.auditReferences = auditReferences;
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeObject(value: unknown, label: string): Record<string, unknown> {
|
||||
const record = object(value, label);
|
||||
rejectSensitiveProjection(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function rejectSensitiveProjection(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.toLowerCase();
|
||||
if (forbiddenFragments.some((fragment) => normalized.includes(fragment)) || forbiddenHostPath.test(value)) {
|
||||
throw new Error("Game Client Bridge response contains sensitive connection or host data");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(rejectSensitiveProjection);
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (forbiddenKeys.has(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) {
|
||||
throw new Error("Game Client Bridge response contains a forbidden field");
|
||||
}
|
||||
rejectSensitiveProjection(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown, label: string): GameClientBridgeJsonObject {
|
||||
const record = object(value, label);
|
||||
return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, jsonValue(child, `${label}.${key}`)]));
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown, label: string): GameClientBridgeJsonValue {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (Array.isArray(value)) return value.map((child, index) => jsonValue(child, `${label}[${index}]`));
|
||||
if (value && typeof value === "object") return jsonObject(value, label);
|
||||
throw new Error(`${label} must be JSON-compatible`);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return string(value, label);
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${label} must be a non-negative safe integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, label: string): string[] {
|
||||
return array(value, label).map((item, index) => string(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function optionalStringArray(value: unknown, label: string): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return stringArray(value, label);
|
||||
}
|
||||
|
||||
function commandState(value: unknown): GameClientBridgeCommandState {
|
||||
const parsed = string(value, "state") as GameClientBridgeCommandState;
|
||||
if (!commandStates.has(parsed)) throw new Error("Game Client Bridge command state is invalid");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function approvalState(value: unknown): GameClientBridgeApprovalState {
|
||||
const parsed = string(value, "approvalState") as GameClientBridgeApprovalState;
|
||||
if (!approvalStates.has(parsed)) throw new Error("Game Client Bridge approval state is invalid");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resultStatus(value: unknown): GameClientBridgeResultStatus {
|
||||
const parsed = string(value, "result.status") as GameClientBridgeResultStatus;
|
||||
if (!resultStatuses.has(parsed)) throw new Error("Game Client Bridge result status is invalid");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function copyOptionalString<T extends object>(source: Record<string, unknown>, target: T, key: keyof T): void {
|
||||
const parsed = optionalString(source[key as string], String(key));
|
||||
if (parsed) (target as Record<keyof T, unknown>)[key] = parsed;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GameClientBridgeJsonObject, GameClientBridgeSnapshotResponse } from "../api/types";
|
||||
import { projectScumOperationsSnapshots } from "./scumOperations";
|
||||
|
||||
const now = "2026-07-20T08:00:00Z";
|
||||
|
||||
function snapshot(type: string, payload: GameClientBridgeJsonObject, sequence = 1): GameClientBridgeSnapshotResponse {
|
||||
return {
|
||||
id: `${type}-${sequence}`,
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client",
|
||||
type,
|
||||
schemaVersion: "1",
|
||||
streamKey: "current",
|
||||
sequence,
|
||||
observedAt: now,
|
||||
payload,
|
||||
retention: { keepForSeconds: 3600, maxRecords: 24 },
|
||||
createdAt: now,
|
||||
expiresAt: "2026-07-20T09:00:00Z"
|
||||
};
|
||||
}
|
||||
|
||||
describe("SCUM operations snapshot projection", () => {
|
||||
it("projects companion, player, session, squad, vehicle and flag snapshots", () => {
|
||||
const view = projectScumOperationsSnapshots([
|
||||
snapshot("companion.health", { status: "online", version: "1.2.0", observedAt: now, latencyMs: 24, capabilities: ["game-client.bridge"] }),
|
||||
snapshot("online.sessions", { observedAt: now, onlineCount: 1, sessions: [{ sessionId: "game-session-1", playerName: "Moonlight", startedAt: now }] }),
|
||||
snapshot("players", { observedAt: now, players: [{ playerId: "player-1", playerName: "Moonlight", status: "online", squadId: "squad-1", pingMs: 33 }] }),
|
||||
snapshot("squads", { observedAt: now, squads: [{ squadId: "squad-1", name: "Lunar", memberCount: 4, leaderPlayerId: "player-1" }] }),
|
||||
snapshot("vehicles", { observedAt: now, vehicles: [{ vehicleId: "vehicle-1", vehicleType: "truck", status: "parked", ownerPlayerId: "player-1", fuelPercent: 70, healthPercent: 80 }] }),
|
||||
snapshot("flags", { observedAt: now, flags: [{ flagId: "flag-1", status: "active", squadId: "squad-1", radiusMeters: 25 }] })
|
||||
]);
|
||||
|
||||
expect(view).toMatchObject({
|
||||
health: { status: "online", version: "1.2.0", latencyMs: 24 },
|
||||
sessions: { total: 1, items: [{ sessionId: "game-session-1" }] },
|
||||
players: { total: 1, items: [{ playerId: "player-1", squadId: "squad-1" }] },
|
||||
squads: { total: 1, items: [{ memberCount: 4 }] },
|
||||
vehicles: { total: 1, items: [{ fuelPercent: 70 }] },
|
||||
flags: { total: 1, items: [{ radiusMeters: 25 }] }
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the newest sequence and redacts sensitive-looking display strings", () => {
|
||||
const view = projectScumOperationsSnapshots([
|
||||
snapshot("players", { observedAt: now, players: [{ playerId: "player-old", playerName: "Old", status: "offline" }] }, 1),
|
||||
{ ...snapshot("players", { observedAt: now, players: [{ playerId: "player-new", playerName: "token=raw-secret /Users/operator/file", status: "online" }] }, 2), observedAt: now }
|
||||
]);
|
||||
expect(view.players.items[0]).toMatchObject({ playerId: "player-new", status: "online" });
|
||||
expect(view.players.items[0]?.playerName).not.toContain("raw-secret");
|
||||
expect(view.players.items[0]?.playerName).not.toContain("/Users/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { GameClientBridgeJsonObject, GameClientBridgeSnapshotResponse } from "../api/types";
|
||||
import type {
|
||||
ScumCompanionHealthView,
|
||||
ScumFlagView,
|
||||
ScumOperationsSnapshotView,
|
||||
ScumPlayerView,
|
||||
ScumSessionView,
|
||||
ScumSnapshotCollection,
|
||||
ScumSquadView,
|
||||
ScumVehicleView
|
||||
} from "../contracts/scumOperations";
|
||||
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
||||
|
||||
const maxVisibleItems = 50;
|
||||
|
||||
export function projectScumOperationsSnapshots(snapshots: GameClientBridgeSnapshotResponse[]): ScumOperationsSnapshotView {
|
||||
return {
|
||||
health: projectHealth(latestPayload(snapshots, "companion.health")),
|
||||
sessions: projectCollection(latestSnapshot(snapshots, "online.sessions"), "sessions", projectSession),
|
||||
players: projectCollection(latestSnapshot(snapshots, "players"), "players", projectPlayer),
|
||||
squads: projectCollection(latestSnapshot(snapshots, "squads"), "squads", projectSquad),
|
||||
vehicles: projectCollection(latestSnapshot(snapshots, "vehicles"), "vehicles", projectVehicle),
|
||||
flags: projectCollection(latestSnapshot(snapshots, "flags"), "flags", projectFlag)
|
||||
};
|
||||
}
|
||||
|
||||
function latestSnapshot(snapshots: GameClientBridgeSnapshotResponse[], type: string): GameClientBridgeSnapshotResponse | undefined {
|
||||
return snapshots
|
||||
.filter((snapshot) => snapshot.type === type)
|
||||
.sort((left, right) => right.observedAt.localeCompare(left.observedAt) || right.sequence - left.sequence)[0];
|
||||
}
|
||||
|
||||
function latestPayload(snapshots: GameClientBridgeSnapshotResponse[], type: string): GameClientBridgeJsonObject | undefined {
|
||||
return latestSnapshot(snapshots, type)?.payload;
|
||||
}
|
||||
|
||||
function projectHealth(payload: GameClientBridgeJsonObject | undefined): ScumCompanionHealthView | undefined {
|
||||
if (!payload) return undefined;
|
||||
const status = readText(payload.status, 20);
|
||||
return {
|
||||
status: status === "online" || status === "degraded" || status === "offline" ? status : "unknown",
|
||||
version: readOptionalText(payload.version, 40),
|
||||
observedAt: readOptionalText(payload.observedAt, 64),
|
||||
latencyMs: readOptionalNumber(payload.latencyMs, 0, 30000),
|
||||
capabilities: readArray(payload.capabilities).map((value) => readText(value, 80)).filter(Boolean).slice(0, 16)
|
||||
};
|
||||
}
|
||||
|
||||
function projectCollection<T>(
|
||||
snapshot: GameClientBridgeSnapshotResponse | undefined,
|
||||
key: string,
|
||||
project: (value: unknown) => T | null
|
||||
): ScumSnapshotCollection<T> {
|
||||
const values = snapshot ? readArray(snapshot.payload[key]) : [];
|
||||
return {
|
||||
observedAt: snapshot?.observedAt,
|
||||
total: values.length,
|
||||
items: values.slice(0, maxVisibleItems).map(project).filter((value): value is T => value !== null)
|
||||
};
|
||||
}
|
||||
|
||||
function projectSession(value: unknown): ScumSessionView | null {
|
||||
const record = readObject(value);
|
||||
const sessionId = readText(record?.sessionId, 120);
|
||||
const playerName = readText(record?.playerName, 80);
|
||||
return sessionId && playerName ? { sessionId, playerName, startedAt: readOptionalText(record?.startedAt, 64) } : null;
|
||||
}
|
||||
|
||||
function projectPlayer(value: unknown): ScumPlayerView | null {
|
||||
const record = readObject(value);
|
||||
const playerId = readText(record?.playerId, 96);
|
||||
const playerName = readText(record?.playerName, 80);
|
||||
const status = readText(record?.status, 20);
|
||||
return playerId && playerName && status ? {
|
||||
playerId,
|
||||
playerName,
|
||||
status,
|
||||
squadId: readOptionalText(record?.squadId, 96),
|
||||
pingMs: readOptionalNumber(record?.pingMs, 0, 10000),
|
||||
lastSeenAt: readOptionalText(record?.lastSeenAt, 64)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function projectSquad(value: unknown): ScumSquadView | null {
|
||||
const record = readObject(value);
|
||||
const squadId = readText(record?.squadId, 96);
|
||||
const name = readText(record?.name, 80);
|
||||
const memberCount = readOptionalNumber(record?.memberCount, 0, 64);
|
||||
return squadId && name && memberCount !== undefined ? {
|
||||
squadId,
|
||||
name,
|
||||
memberCount,
|
||||
leaderPlayerId: readOptionalText(record?.leaderPlayerId, 96),
|
||||
lastActiveAt: readOptionalText(record?.lastActiveAt, 64)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function projectVehicle(value: unknown): ScumVehicleView | null {
|
||||
const record = readObject(value);
|
||||
const vehicleId = readText(record?.vehicleId, 96);
|
||||
const vehicleType = readText(record?.vehicleType, 80);
|
||||
const status = readText(record?.status, 20);
|
||||
return vehicleId && vehicleType && status ? {
|
||||
vehicleId,
|
||||
vehicleType,
|
||||
status,
|
||||
ownerPlayerId: readOptionalText(record?.ownerPlayerId, 96),
|
||||
squadId: readOptionalText(record?.squadId, 96),
|
||||
fuelPercent: readOptionalNumber(record?.fuelPercent, 0, 100),
|
||||
healthPercent: readOptionalNumber(record?.healthPercent, 0, 100),
|
||||
lastSeenAt: readOptionalText(record?.lastSeenAt, 64)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function projectFlag(value: unknown): ScumFlagView | null {
|
||||
const record = readObject(value);
|
||||
const flagId = readText(record?.flagId, 96);
|
||||
const status = readText(record?.status, 20);
|
||||
return flagId && status ? {
|
||||
flagId,
|
||||
status,
|
||||
ownerPlayerId: readOptionalText(record?.ownerPlayerId, 96),
|
||||
squadId: readOptionalText(record?.squadId, 96),
|
||||
radiusMeters: readOptionalNumber(record?.radiusMeters, 0, 5000),
|
||||
lastUpdatedAt: readOptionalText(record?.lastUpdatedAt, 64)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function readObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function readArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readText(value: unknown, maxLength: number): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return (safeDiagnosticText(value.slice(0, maxLength), "") ?? "").trim();
|
||||
}
|
||||
|
||||
function readOptionalText(value: unknown, maxLength: number): string | undefined {
|
||||
return readText(value, maxLength) || undefined;
|
||||
}
|
||||
|
||||
function readOptionalNumber(value: unknown, minimum: number, maximum: number): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum ? value : undefined;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const plugin: GamePluginResponse = {
|
||||
pages: [],
|
||||
tags: [],
|
||||
aiPurposes: [],
|
||||
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
|
||||
status: "installed",
|
||||
runtimeProfiles: {
|
||||
discovery: [{ key: "root-check", kind: "file.exists", targetKey: "server-root", required: true }],
|
||||
|
||||
Reference in New Issue
Block a user