功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
+25 -3
View File
@@ -9,7 +9,7 @@ const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
baseUrlConfigured: true,
apiKeyConfigured: true,
models: ["gpt-4.1"],
defaultModel: "gpt-4.1",
@@ -35,6 +35,7 @@ const plugin: GamePluginResponse = {
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] },
status: "installed"
};
@@ -57,6 +58,7 @@ const marketplacePlugin: MarketplacePluginResponse = {
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
status: "installed",
source: "platform-registry"
};
@@ -181,7 +183,7 @@ describe("PlatformApiClient AI providers", () => {
id: provider.id,
name: provider.name,
kind: provider.kind,
baseUrl: provider.baseUrl,
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
models: provider.models,
defaultModel: provider.defaultModel,
@@ -725,11 +727,13 @@ describe("PlatformApiClient AI providers", () => {
);
});
it("keeps raw key fields out of provider responses", () => {
it("keeps raw key and base URL fields out of provider responses", () => {
expect("apiKey" in provider).toBe(false);
expect("rawApiKey" in provider).toBe(false);
expect(provider.apiKeyConfigured).toBe(true);
expect("apiKeyRef" in provider).toBe(false);
expect("baseUrl" in provider).toBe(false);
expect(provider.baseUrlConfigured).toBe(true);
});
it("calls auth endpoints and attaches bearer sessions", async () => {
@@ -804,6 +808,24 @@ describe("PlatformApiClient AI providers", () => {
});
expect(onAuthFailure).not.toHaveBeenCalled();
});
it("surfaces allow-listed plugin capability denials", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
code: "forbidden",
message: "plugin does not declare required permission: server.run.distribution"
}), { status: 403, headers: { "Content-Type": "application/json" } })));
const client = new PlatformApiClient("/api/v1", () => "admin-session");
await expect(client.generateRunDistribution("server-local-debug", {
targetOs: "linux",
targetArch: "amd64",
idempotencyKey: "test"
})).rejects.toMatchObject({
status: 403,
code: "forbidden",
message: "插件未声明所需权限:server.run.distribution"
});
});
});
function jsonResponse(body: unknown): Response {
+169 -1
View File
@@ -8,6 +8,11 @@ import type {
AiProviderUpdateRequest,
AIInvocationRequest,
AIInvocationResponse,
AIConfigDiffApprovalResponse,
AIConfigDiffListResponse,
AlertListResponse,
AlertResponse,
AlertRetryResponse,
ApiErrorResponse,
ArtifactContentChunk,
ArtifactDownloadReferenceResponse,
@@ -16,8 +21,16 @@ import type {
AuthSessionResponse,
AuditEventListResponse,
ClientManagerBuildRequest,
ClientManagerControlRequest,
ClientManagerDeployRequest,
ClientManagerDistributionResponse,
ClientManagerDownloadRequest,
ClientManagerInstallationListResponse,
ClientManagerInstallationResponse,
ClientManagerRetryRequest,
ClientManagerRevokeSessionRequest,
ClientManagerUninstallRequest,
ClientManagerUpdateRequest,
ComponentKeyResponse,
ComponentKeyResetRequest,
CurrentUserResponse,
@@ -25,6 +38,15 @@ import type {
DependencyJobRequest,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GameClientBridgeCancelRequest,
GameClientBridgeCancelResponse,
GameClientBridgeCommandFilterRequest,
GameClientBridgeCommandListResponse,
GameClientBridgeCommandResponse,
GameClientBridgeQueueRequest,
GameClientBridgeSnapshotListResponse,
GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse,
GamePluginListResponse,
HealthResponse,
JobCreateRequest,
@@ -42,6 +64,11 @@ import type {
MarketplacePluginResponse,
MarketplacePluginStateRequest,
PlatformResourceUsageResponse,
ProductionCapacitySummaryResponse,
CapacityAdmissionDecisionResponse,
PluginLifecycleActionRequest,
PluginLifecycleActionResponse,
PluginLifecycleListResponse,
PluginBridgeAuthorizeRequest,
PluginBridgeAuthorizeResponse,
PluginBridgeExecuteRequest,
@@ -86,6 +113,15 @@ import type {
} from "./types";
import { readWebRuntimeEnv } from "../schemas/env";
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "../schemas/clientManagerLifecycle";
import {
parseSafeGameClientBridgeCancellation,
parseSafeGameClientBridgeCommand,
parseSafeGameClientBridgeCommandList,
parseSafeGameClientBridgeSnapshotList,
parseSafeGameClientBridgeStatus
} from "../schemas/gameClientBridge";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
let platformApiSessionToken: string | null = null;
let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null;
@@ -298,6 +334,74 @@ export class PlatformApiClient {
});
}
async listClientManagerLifecycles(id: string): Promise<ClientManagerInstallationListResponse> {
return parseSafeClientManagerLifecycleList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers`));
}
async getClientManagerLifecycle(id: string, profileKey: string): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/${encodeURIComponent(profileKey)}`));
}
async deployClientManager(id: string, request: ClientManagerDeployRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/deploy`, { method: "POST", body: request }));
}
async controlClientManager(id: string, request: ClientManagerControlRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/control`, { method: "POST", body: request }));
}
async updateClientManager(id: string, request: ClientManagerUpdateRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/update`, { method: "POST", body: request }));
}
async retryClientManagerLifecycle(id: string, request: ClientManagerRetryRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/retry`, { method: "POST", body: request }));
}
async revokeClientManagerSession(id: string, request: ClientManagerRevokeSessionRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/revoke-session`, { method: "POST", body: request }));
}
async uninstallClientManager(id: string, request: ClientManagerUninstallRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/uninstall`, { method: "POST", body: request }));
}
async getGameClientBridgeStatus(id: string): Promise<GameClientBridgeStatusResponse> {
return parseSafeGameClientBridgeStatus(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge`));
}
async listGameClientBridgeCommands(id: string, filter: GameClientBridgeCommandFilterRequest = {}): Promise<GameClientBridgeCommandListResponse> {
const params = new URLSearchParams();
if (filter.profileKey) params.set("profileKey", filter.profileKey);
if (filter.state) params.set("state", filter.state);
if (filter.commandType) params.set("commandType", filter.commandType);
const query = params.toString();
return parseSafeGameClientBridgeCommandList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands${query ? `?${query}` : ""}`));
}
async queueGameClientBridgeCommand(id: string, request: GameClientBridgeQueueRequest): Promise<GameClientBridgeCommandResponse> {
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
}
async getGameClientBridgeCommand(id: string, commandId: string): Promise<GameClientBridgeCommandResponse> {
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`));
}
async cancelGameClientBridgeCommand(id: string, commandId: string, request: GameClientBridgeCancelRequest = {}): Promise<GameClientBridgeCancelResponse> {
return parseSafeGameClientBridgeCancellation(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}/cancel`, { method: "POST", body: request }));
}
async listGameClientBridgeSnapshots(id: string, query: GameClientBridgeSnapshotQuery = {}): Promise<GameClientBridgeSnapshotListResponse> {
const params = new URLSearchParams();
if (query.profileKey) params.set("profileKey", query.profileKey);
if (query.type) params.set("type", query.type);
if (query.streamKey) params.set("streamKey", query.streamKey);
if (query.observedAfter) params.set("observedAfter", query.observedAfter);
if (query.limit !== undefined) params.set("limit", String(query.limit));
const search = params.toString();
return parseSafeGameClientBridgeSnapshotList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/snapshots${search ? `?${search}` : ""}`));
}
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
method: "POST",
@@ -386,6 +490,55 @@ export class PlatformApiClient {
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
}
async getProductionCapacity(): Promise<ProductionCapacitySummaryResponse> {
return this.request<ProductionCapacitySummaryResponse>("/production/capacity");
}
async checkCapacityAdmission(request: { serverInstanceId?: string; runEndpointId?: string; capability: string; targetKey?: string; idempotencyKey?: string }): Promise<CapacityAdmissionDecisionResponse> {
return this.request<CapacityAdmissionDecisionResponse>("/production/capacity/admission", { method: "POST", body: request });
}
async listAlerts(filter: { state?: string; sourceKind?: string; sourceId?: string; severity?: string } = {}): Promise<AlertListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<AlertListResponse>(`/alerts${query ? `?${query}` : ""}`);
}
async acknowledgeAlert(id: string, note = ""): Promise<AlertResponse> {
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/acknowledge`, { method: "POST", body: { note } });
}
async resolveAlert(id: string, note = ""): Promise<AlertResponse> {
return this.request<AlertResponse>(`/alerts/${encodeURIComponent(id)}/resolve`, { method: "POST", body: { note } });
}
async retryAlert(id: string, idempotencyKey: string): Promise<AlertRetryResponse> {
return this.request<AlertRetryResponse>(`/alerts/${encodeURIComponent(id)}/retry`, { method: "POST", body: { idempotencyKey } });
}
async listPluginLifecycles(filter: { pluginId?: string; serverInstanceId?: string; currentState?: string } = {}): Promise<PluginLifecycleListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<PluginLifecycleListResponse>(`/plugin-lifecycles${query ? `?${query}` : ""}`);
}
async runPluginLifecycle(pluginId: string, request: PluginLifecycleActionRequest): Promise<PluginLifecycleActionResponse> {
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
}
async listAIConfigDiffs(filter: { serverInstanceId?: string; pluginId?: string; state?: string } = {}): Promise<AIConfigDiffListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<AIConfigDiffListResponse>(`/ai/config-diffs${query ? `?${query}` : ""}`);
}
async approveAIConfigDiff(id: string, idempotencyKey: string): Promise<AIConfigDiffApprovalResponse> {
return this.request<AIConfigDiffApprovalResponse>(`/ai/config-diffs/${encodeURIComponent(id)}/approve`, { method: "POST", body: { idempotencyKey } });
}
async listServerMetrics(): Promise<ServerMetricsListResponse> {
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
}
@@ -556,7 +709,7 @@ async function responseError(response: Response): Promise<PlatformApiError> {
const message = response.status === 401
? "会话已失效,请重新登录。"
: response.status === 403
? "没有权限访问该资源。"
? safeForbiddenMessage(apiError?.message)
: apiError?.message ?? `request failed: ${response.status}`;
const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message);
if (response.status === 401) {
@@ -566,6 +719,21 @@ async function responseError(response: Response): Promise<PlatformApiError> {
return error;
}
function safeForbiddenMessage(apiMessage?: string): string {
const sanitized = safeDiagnosticText(apiMessage, "")?.trim();
if (!sanitized || sanitized === "account is not allowed to access this resource") {
return "没有权限访问该资源。";
}
const missingPermission = sanitized.match(/^plugin does not declare required permission:\s*([a-z0-9._-]+)$/i);
if (missingPermission) {
return `插件未声明所需权限:${missingPermission[1]}`;
}
if (sanitized === "plugin is not installed") {
return "插件未安装,不能执行该操作。";
}
return "没有权限访问该资源。";
}
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
const params = new URLSearchParams();
if (filter.status && filter.status !== "all") {
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
const lifecycle = {
id: "installation-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client-manager", targetOs: "windows", targetArch: "amd64",
status: "available", phase: "artifact available", desiredVersion: "1.0.0", desiredRevision: "rev-1", desiredArtifactId: "artifact-1", keyGeneration: 1,
deploymentGeneration: 0, health: "unknown", healthReason: "component is not installed", retryable: false, requiresRedeploy: false, updatedAt: "2026-07-18T08:00:00Z",
distribution: { id: "distribution-1", artifactId: "artifact-1", sourceRevision: "rev-1", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 1, status: "available" },
actions: [{ operation: "deploy", available: true }, { operation: "uninstall", available: false, reason: "not installed" }]
};
describe("PlatformApiClient Client Manager lifecycle", () => {
afterEach(() => vi.unstubAllGlobals());
it("uses typed Platform lifecycle routes and preserves action bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify(url.endsWith("/client-managers") && (init?.method ?? "GET") === "GET" ? { items: [lifecycle], count: 1 } : lifecycle), { status: 200, headers: { "Content-Type": "application/json" } });
}));
const client = new PlatformApiClient("/api/v1", () => "session-token");
await expect(client.listClientManagerLifecycles("server-1")).resolves.toMatchObject({ count: 1 });
await expect(client.getClientManagerLifecycle("server-1", "scum-client-manager")).resolves.toMatchObject({ profileKey: "scum-client-manager" });
await client.deployClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" });
await client.controlClientManager("server-1", { profileKey: "scum-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" });
await client.updateClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" });
await client.retryClientManagerLifecycle("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" });
await client.revokeClientManagerSession("server-1", { profileKey: "scum-client-manager", reason: "operator revoked component session" });
await client.uninstallClientManager("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" });
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/server-instances/server-1/client-managers",
"GET /api/v1/server-instances/server-1/client-managers/scum-client-manager",
"POST /api/v1/server-instances/server-1/client-managers/deploy",
"POST /api/v1/server-instances/server-1/client-managers/control",
"POST /api/v1/server-instances/server-1/client-managers/update",
"POST /api/v1/server-instances/server-1/client-managers/retry",
"POST /api/v1/server-instances/server-1/client-managers/revoke-session",
"POST /api/v1/server-instances/server-1/client-managers/uninstall"
]);
expect(calls[4]?.body).toMatchObject({ approved: true, distributionId: "distribution-2" });
expect(calls[7]?.body).toMatchObject({ confirmed: true });
});
});
+3
View File
@@ -61,3 +61,6 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters.
# Client Manager API projection
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
import type { GameClientBridgeManifestResponse, GameClientBridgeQueueRequest, GamePluginResponse, MarketplacePluginResponse } from "./types";
const now = "2026-07-20T08:00:00Z";
const later = "2026-07-20T09:00:00Z";
const status = {
serverInstanceId: "server-1",
pluginId: "game.scum",
available: false,
reason: "compatible companion is offline",
profiles: [{
pluginId: "game.scum",
profileKey: "scum-client",
available: false,
reason: "component heartbeat is unavailable",
commandTypes: ["scum.announcement.send"],
snapshotTypes: ["scum.players"],
queryTemplateKeys: ["scum.player.search"]
}]
} as const;
const pendingCommand = {
id: "command-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
commandType: "scum.announcement.send",
priority: 20,
state: "pending",
approvalState: "pending",
requesterId: "user-1",
auditReferences: ["audit-command-1"],
expiresAt: later,
createdAt: now,
updatedAt: now
} as const;
const completedCommand = {
...pendingCommand,
state: "succeeded",
approvalState: "approved",
resultSummary: "announcement delivered",
result: {
status: "succeeded",
summary: "announcement delivered",
payload: { delivered: true, recipientCount: 12 },
completedAt: later
},
completedAt: later,
updatedAt: later
} as const;
const cancellation = {
commandId: pendingCommand.id,
state: "cancelled",
cancellation: { requestedBy: "user-1", reason: "maintenance window changed", cancelledAt: later },
auditReferences: ["audit-command-1", "audit-command-cancel-1"],
updatedAt: later
} as const;
const snapshot = {
id: "snapshot-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
type: "scum.players",
schemaVersion: "1",
streamKey: "current",
sequence: 7,
observedAt: now,
payload: { players: [{ playerId: "player-1", displayName: "Moonlight" }] },
retention: { keepForSeconds: 3600, maxRecords: 24 },
auditReferences: ["audit-snapshot-1"],
createdAt: now,
expiresAt: later
} as const;
const manifestDeclaration: GameClientBridgeManifestResponse = {
commands: [{
type: "scum.announcement.send",
title: "Send announcement",
permission: "server.game-client.command",
approvalLevel: "operator",
payloadSchemaRef: "schemas/bridge/commands/announcement.request.json",
resultSchemaRef: "schemas/bridge/commands/announcement.result.json",
timeoutSeconds: 30,
maxPayloadBytes: 4096
}],
snapshots: [{ type: "scum.players", schemaVersion: "1", schemaRef: "schemas/bridge/snapshots/players.json", keepForSeconds: 3600, maxRecords: 24 }],
queryTemplates: [{
key: "scum.player.search",
title: "Search players",
permission: "server.game-client.read",
engine: "sqlite",
transportKey: "scum-database",
targetKey: "scum-db",
parameterSchemaRef: "schemas/bridge/queries/player-search.request.json",
resultSchemaRef: "schemas/bridge/queries/player-search.result.json",
maxRows: 50,
timeoutSeconds: 10
}],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["scum.announcement.send"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
companion: {
profileKey: "scum-client-manager",
configTemplateKey: "client-config",
configSchemaRef: "schemas/companion/config.schema.json",
configFormat: "yaml",
platformBaseUrlSource: "run-control",
registrationProof: "hmac-sha256",
proofMaterialSource: "component-package",
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
sessionMode: "component-session",
tlsPolicy: "verify-system-roots",
heartbeatIntervalSeconds: 30,
commandPollIntervalSeconds: 5,
requestTimeoutSeconds: 15
}
};
const pluginBridgeProjection: Pick<GamePluginResponse, "gameClientBridge"> & Pick<MarketplacePluginResponse, "gameClientBridge"> = {
gameClientBridge: manifestDeclaration
};
describe("PlatformApiClient Game Client Bridge operator API", () => {
afterEach(() => vi.unstubAllGlobals());
it("types plugin and marketplace manifest declarations with approval metadata", () => {
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ approvalLevel: "operator" }], queryTemplates: [{ engine: "sqlite" }], companion: { tlsPolicy: "verify-system-roots", sessionMode: "component-session" } });
expect(JSON.stringify(pluginBridgeProjection)).not.toMatch(/authKey|componentKey|sessionToken|credential|secretRef/i);
});
it("uses only server-scoped operator routes and preserves bounded filters and bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.endsWith("/game-client-bridge")) return jsonResponse(status);
if (url.includes("/game-client-bridge/commands?") && method === "GET") return jsonResponse({ items: [pendingCommand], count: 1 });
if (url.endsWith("/game-client-bridge/commands") && method === "POST") return jsonResponse(pendingCommand, 202);
if (url.endsWith(`/game-client-bridge/commands/${pendingCommand.id}`) && method === "GET") return jsonResponse(completedCommand);
if (url.endsWith(`/game-client-bridge/commands/${pendingCommand.id}/cancel`) && method === "POST") return jsonResponse(cancellation);
if (url.includes("/game-client-bridge/snapshots?")) return jsonResponse({ items: [snapshot], count: 1 });
throw new Error(`unexpected request: ${method} ${url}`);
}));
const client = new PlatformApiClient("/api/v1", () => "operator-session");
const queueRequest: GameClientBridgeQueueRequest = {
profileKey: "scum-client",
commandType: "scum.announcement.send",
payload: { message: "Restart in ten minutes", channels: ["global"] },
idempotencyKey: "announcement-1",
priority: 20,
expiresAt: later
};
await expect(client.getGameClientBridgeStatus("server-1")).resolves.toMatchObject({ available: false, profiles: [{ profileKey: "scum-client" }] });
await expect(client.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.announcement.send" })).resolves.toMatchObject({ count: 1 });
await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending", approvalState: "pending" });
await expect(client.getGameClientBridgeCommand("server-1", pendingCommand.id)).resolves.toMatchObject({ result: { status: "succeeded", payload: { delivered: true } } });
await expect(client.cancelGameClientBridgeCommand("server-1", pendingCommand.id, { reason: "maintenance window changed" })).resolves.toMatchObject({ state: "cancelled" });
await expect(client.listGameClientBridgeSnapshots("server-1", { profileKey: "scum-client", type: "scum.players", streamKey: "current", observedAfter: now, limit: 20 })).resolves.toMatchObject({ count: 1, items: [{ sequence: 7 }] });
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/server-instances/server-1/game-client-bridge",
"GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.announcement.send",
"POST /api/v1/server-instances/server-1/game-client-bridge/commands",
"GET /api/v1/server-instances/server-1/game-client-bridge/commands/command-1",
"POST /api/v1/server-instances/server-1/game-client-bridge/commands/command-1/cancel",
"GET /api/v1/server-instances/server-1/game-client-bridge/snapshots?profileKey=scum-client&type=scum.players&streamKey=current&observedAfter=2026-07-20T08%3A00%3A00Z&limit=20"
]);
expect(calls[2]?.body).toEqual(queueRequest);
expect(calls[4]?.body).toEqual({ reason: "maintenance window changed" });
expect(JSON.stringify(calls)).not.toMatch(/sessionToken|componentKey|secretRef|hostPath|dsn|socket|credential|runEndpoint/i);
expect(calls.every((call) => !call.url.includes("/companion/"))).toBe(true);
});
it("URL-encodes server and command identifiers", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(completedCommand));
vi.stubGlobal("fetch", fetchMock);
await new PlatformApiClient("/api/v1").getGameClientBridgeCommand("server/unsafe", "command/unsafe");
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server%2Funsafe/game-client-bridge/commands/command%2Funsafe");
});
});
function jsonResponse(value: unknown, statusCode = 200): Response {
return new Response(JSON.stringify(value), { status: statusCode, headers: { "Content-Type": "application/json" } });
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
describe("PlatformApiClient production operations", () => {
afterEach(() => vi.unstubAllGlobals());
it("uses Platform-only governance routes and bounded request bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify({ items: [], count: 0, endpoints: [], totalMaxJobs: 0, totalRunningJobs: 0, totalQueuedJobs: 0, activeAlerts: 0, generatedAt: "2026-07-18T00:00:00Z", status: "queued", installation: {}, job: {}, decision: {}, alert: {} }), { status: 200, headers: { "Content-Type": "application/json" } });
}));
const client = new PlatformApiClient("/api/v1", () => "session-token");
await client.getProductionCapacity();
await client.listAlerts({ state: "active" });
await client.acknowledgeAlert("alert-1", "reviewed");
await client.resolveAlert("alert-1", "resolved");
await client.retryAlert("alert-1", "retry-1");
await client.listPluginLifecycles({ pluginId: "game.scum" });
await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
await client.listAIConfigDiffs({ state: "pending" });
await client.approveAIConfigDiff("diff-1", "approve-1");
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/production/capacity",
"GET /api/v1/alerts?state=active",
"POST /api/v1/alerts/alert-1/acknowledge",
"POST /api/v1/alerts/alert-1/resolve",
"POST /api/v1/alerts/alert-1/retry",
"GET /api/v1/plugin-lifecycles?pluginId=game.scum",
"POST /api/v1/plugin-lifecycles/game.scum/actions",
"GET /api/v1/ai/config-diffs?state=pending",
"POST /api/v1/ai/config-diffs/diff-1/approve"
]);
const serialized = JSON.stringify(calls);
expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i);
expect(calls[6]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false });
});
});
+472 -1
View File
@@ -13,6 +13,199 @@ export type DependencyState = "unknown" | "present" | "missing" | "installing" |
export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed";
export type ServerLifecycleAction = "create" | "start" | "stop" | "status";
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled";
export type GameClientBridgeJsonValue = string | number | boolean | null | GameClientBridgeJsonValue[] | GameClientBridgeJsonObject;
export interface GameClientBridgeJsonObject {
[key: string]: GameClientBridgeJsonValue;
}
export interface GameClientBridgeCommandDeclarationResponse {
type: string;
title: string;
permission: string;
approvalLevel: GameClientBridgeApprovalLevel;
payloadSchemaRef: string;
resultSchemaRef?: string;
timeoutSeconds: number;
maxPayloadBytes: number;
}
export interface GameClientBridgeSnapshotDeclarationResponse {
type: string;
schemaVersion: string;
schemaRef: string;
keepForSeconds: number;
maxRecords: number;
}
export interface GameClientBridgeQueryTemplateDeclarationResponse {
key: string;
title: string;
permission: string;
engine: "sqlite";
transportKey: string;
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
maxRows: number;
timeoutSeconds: number;
}
export interface GameClientBridgePageContractResponse {
pageKey: string;
commandTypes?: string[];
snapshotTypes?: string[];
queryTemplateKeys?: string[];
}
export interface GameClientBridgeCompanionDeclarationResponse {
profileKey: string;
configTemplateKey: string;
configSchemaRef: string;
configFormat: "yaml";
platformBaseUrlSource: "run-control";
registrationProof: "hmac-sha256";
proofMaterialSource: "component-package";
proofMaterialEnv: string;
sessionMode: "component-session";
tlsPolicy: "verify-system-roots";
heartbeatIntervalSeconds: number;
commandPollIntervalSeconds: number;
requestTimeoutSeconds: number;
}
export interface GameClientBridgeManifestResponse {
commands: GameClientBridgeCommandDeclarationResponse[];
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
queryTemplates?: GameClientBridgeQueryTemplateDeclarationResponse[];
commandRetentionSeconds: number;
maxCommands: number;
pages?: GameClientBridgePageContractResponse[];
companion?: GameClientBridgeCompanionDeclarationResponse;
}
export interface GameClientBridgeProfileDeclarationResponse {
pluginId: string;
profileKey: string;
available: boolean;
reason?: string;
commandTypes: string[];
snapshotTypes: string[];
queryTemplateKeys: string[];
}
export interface GameClientBridgeStatusResponse {
serverInstanceId: string;
pluginId: string;
available: boolean;
reason?: string;
profiles: GameClientBridgeProfileDeclarationResponse[];
}
export interface GameClientBridgeCommandResultResponse {
status: GameClientBridgeResultStatus;
summary?: string;
payload?: GameClientBridgeJsonObject;
completedAt: string;
}
export interface GameClientBridgeCommandCancellationResponse {
requestedBy?: string;
reason?: string;
cancelledAt: string;
}
export interface GameClientBridgeCommandResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
commandType: string;
priority: number;
state: GameClientBridgeCommandState;
approvalState: GameClientBridgeApprovalState;
requesterId?: string;
resultSummary?: string;
result?: GameClientBridgeCommandResultResponse;
cancellation?: GameClientBridgeCommandCancellationResponse;
auditReferences?: string[];
expiresAt: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export interface GameClientBridgeCommandListResponse {
items: GameClientBridgeCommandResponse[];
count: number;
}
export interface GameClientBridgeCommandFilterRequest {
profileKey?: string;
state?: GameClientBridgeCommandState;
commandType?: string;
}
export interface GameClientBridgeQueueRequest {
profileKey: string;
commandType: string;
payload: GameClientBridgeJsonObject;
idempotencyKey: string;
priority?: number;
expiresAt: string;
}
export interface GameClientBridgeCancelRequest {
reason?: string;
}
export interface GameClientBridgeCancelResponse {
commandId: string;
state: GameClientBridgeCommandState;
cancellation: GameClientBridgeCommandCancellationResponse;
auditReferences?: string[];
updatedAt: string;
}
export interface GameClientBridgeRetentionResponse {
keepForSeconds: number;
maxRecords?: number;
}
export interface GameClientBridgeSnapshotResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
type: string;
schemaVersion: string;
streamKey: string;
sequence: number;
observedAt: string;
payload: GameClientBridgeJsonObject;
retention: GameClientBridgeRetentionResponse;
auditReferences?: string[];
createdAt: string;
expiresAt: string;
}
export interface GameClientBridgeSnapshotListResponse {
items: GameClientBridgeSnapshotResponse[];
count: number;
}
export interface GameClientBridgeSnapshotQuery {
profileKey?: string;
type?: string;
streamKey?: string;
observedAfter?: string;
limit?: number;
}
export interface PluginPermissionsResponse {
ai: boolean;
logs: boolean;
@@ -74,6 +267,17 @@ export interface RuntimeLogSourceResponse {
retentionDays?: number;
}
export interface RuntimeLogEventResponse {
key: string;
title: string;
sourceKey: string;
eventType: string;
permission: string;
schemaRef: string;
retentionDays: number;
severity: "info" | "notice" | "warning" | "critical";
}
export interface RuntimeTransportProfileResponse {
key: string;
kind: string;
@@ -84,11 +288,18 @@ export interface RuntimeTransportProfileResponse {
export interface RuntimeClientManagerProfileResponse {
key: string;
displayName?: string;
version?: string;
revision?: string;
repository: { url: string; revisionPolicy: string; branch?: string; tag?: string; revision?: string };
supportedTargets: Array<{ os: string; arch: string }>;
build: { system: string; workspaceRef?: string; entryRef?: string };
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
outputArtifacts: string[];
deployment?: { mode: string; executableRef: string; arguments: string[]; autoStart: boolean; requiredRunCapabilities: string[] };
lifecycle?: { actions: string[]; startupTimeoutSeconds: number; stopTimeoutSeconds: number };
health?: { mode: string; intervalSeconds: number; degradedAfterSeconds: number; offlineAfterSeconds: number; requiredCapabilities: string[] };
compatibility?: { minimumVersion?: string; maximumVersion?: string; allowDowngrade: boolean };
updatePolicy?: { strategy: string; requireApproval: boolean; healthConfirmationSeconds: number; retainPrevious: boolean };
}
export interface GamePluginRuntimeProfilesResponse {
@@ -97,6 +308,7 @@ export interface GamePluginRuntimeProfilesResponse {
dependencyProbes?: RuntimeDependencyProbeResponse[];
installPlans?: RuntimeInstallPlanResponse[];
logSources?: RuntimeLogSourceResponse[];
logEvents?: RuntimeLogEventResponse[];
transportProfiles?: RuntimeTransportProfileResponse[];
clientManagers?: RuntimeClientManagerProfileResponse[];
}
@@ -119,8 +331,10 @@ export interface GamePluginResponse {
pages: GamePluginPageResponse[];
tags: string[];
aiPurposes: string[];
productionLifecycle: PluginProductionLifecycleDeclaration;
validationViolations?: string[];
runtimeProfiles?: GamePluginRuntimeProfilesResponse;
gameClientBridge?: GameClientBridgeManifestResponse;
status: GamePluginStatus;
}
@@ -149,7 +363,9 @@ export interface MarketplacePluginResponse {
pages: GamePluginPageResponse[];
tags: string[];
aiPurposes: string[];
productionLifecycle: PluginProductionLifecycleDeclaration;
validationViolations?: string[];
gameClientBridge?: GameClientBridgeManifestResponse;
status: GamePluginStatus;
source: string;
}
@@ -476,6 +692,131 @@ export interface ClientManagerDownloadRequest {
profileKey?: string;
}
export type ClientManagerLifecycleStatus =
| "requested"
| "building"
| "available"
| "deploying"
| "installed"
| "registering"
| "online"
| "degraded"
| "offline"
| "updating"
| "rolling_back"
| "stopping"
| "uninstalled"
| "failed";
export type ClientManagerLifecycleOperation = "deploy" | "start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall";
export interface ClientManagerLifecycleActionResponse {
operation: ClientManagerLifecycleOperation;
available: boolean;
reason?: string;
}
export interface ClientManagerLifecycleJobResponse {
id: string;
state: JobState;
progress: JobProgressBody;
attempt: number;
createdAt: string;
updatedAt: string;
}
export interface ClientManagerDistributionSummaryResponse {
id: string;
artifactId: string;
sourceRevision: string;
targetOs: string;
targetArch: string;
checksum: string;
keyGeneration: number;
status: string;
}
export interface ClientManagerInstallationResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
targetOs: string;
targetArch: string;
status: ClientManagerLifecycleStatus;
phase: string;
desiredVersion?: string;
activeVersion?: string;
previousVersion?: string;
desiredRevision?: string;
activeRevision?: string;
previousRevision?: string;
desiredArtifactId?: string;
activeArtifactId?: string;
previousArtifactId?: string;
keyGeneration: number;
deploymentGeneration: number;
currentJobId?: string;
lastSuccessfulJobId?: string;
lastOperation?: ClientManagerLifecycleOperation;
health: "unknown" | "healthy" | "degraded" | "unhealthy" | "offline";
healthReason?: string;
lastSeenAt?: string;
retryable: boolean;
requiresRedeploy: boolean;
installedAt?: string;
uninstalledAt?: string;
updatedAt: string;
distribution?: ClientManagerDistributionSummaryResponse;
job?: ClientManagerLifecycleJobResponse;
actions: ClientManagerLifecycleActionResponse[];
}
export interface ClientManagerInstallationListResponse {
items: ClientManagerInstallationResponse[];
count: number;
}
export interface ClientManagerDeployRequest {
profileKey: string;
distributionId: string;
expectedDeploymentGeneration?: number;
idempotencyKey: string;
}
export interface ClientManagerControlRequest {
profileKey: string;
operation: "start" | "stop" | "restart" | "status" | "rollback";
expectedDeploymentGeneration: number;
idempotencyKey: string;
}
export interface ClientManagerUpdateRequest {
profileKey: string;
distributionId: string;
expectedDeploymentGeneration: number;
approved: boolean;
idempotencyKey: string;
}
export interface ClientManagerRetryRequest {
profileKey: string;
expectedDeploymentGeneration: number;
idempotencyKey: string;
}
export interface ClientManagerRevokeSessionRequest {
profileKey: string;
reason: string;
}
export interface ClientManagerUninstallRequest {
profileKey: string;
expectedDeploymentGeneration: number;
confirmed: boolean;
idempotencyKey: string;
}
export interface ComponentKeyResetRequest {
componentKind: "run" | "client-manager" | string;
componentKey?: string;
@@ -625,7 +966,7 @@ export interface AiProviderResponse {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
baseUrlConfigured: boolean;
apiKeyConfigured: boolean;
models: string[];
defaultModel?: string;
@@ -1050,8 +1391,138 @@ export interface AIConfigRecommendationResponse {
key: string;
suggestedConfig?: string;
diffSummary: string;
diffId: string;
expiresAt: string;
}
export interface PluginProductionLifecycleDeclaration {
operations: PluginLifecycleOperation[];
dependencyPolicy: "required" | "optional";
approvalRequired: Array<"disable" | "rollback" | "retire">;
}
export type CapacityAdmissionState = "accepted" | "deferred" | "denied";
export interface CapacityAdmissionDecisionResponse {
accepted: boolean;
state: CapacityAdmissionState;
reason: string;
retryAfterSeconds?: number;
serverInstanceId?: string;
runEndpointId?: string;
capability: string;
targetKey?: string;
maxJobs: number;
runningJobs: number;
queuedJobs: number;
pressureCodes?: string[];
checkedAt: string;
alertId?: string;
auditEventId?: string;
}
export interface EndpointCapacityProjectionResponse {
runEndpointId: string;
displayName: string;
status: RunEndpointStatus;
capabilities: string[];
maxJobs: number;
runningJobs: number;
queuedJobs: number;
logBacklogBatches?: number;
artifactBacklogChunks?: number;
pressureCodes?: string[];
summary?: string;
lastHeartbeatAt: string;
lastAdmissionDecision?: CapacityAdmissionState;
lastAdmissionReason?: string;
lastAdmissionCheckedAt?: string;
}
export interface ProductionCapacitySummaryResponse {
endpoints: EndpointCapacityProjectionResponse[];
totalMaxJobs: number;
totalRunningJobs: number;
totalQueuedJobs: number;
activeAlerts: number;
generatedAt: string;
}
export type AlertState = "active" | "acknowledged" | "resolved";
export interface AlertResponse {
id: string;
sourceKind: string;
sourceId: string;
ruleKey: string;
severity: "info" | "warning" | "critical";
state: AlertState;
title: string;
message: string;
occurrenceCount: number;
retryable: boolean;
retryAfterSeconds?: number;
lastJobId?: string;
lastAuditEventId?: string;
lastSeenAt: string;
acknowledgedBy?: string;
acknowledgedAt?: string;
resolvedBy?: string;
resolvedAt?: string;
resolutionNote?: string;
createdAt: string;
updatedAt: string;
}
export interface AlertListResponse { items: AlertResponse[]; count: number; }
export interface AlertRetryResponse { status: string; alert: AlertResponse; decision: CapacityAdmissionDecisionResponse; }
export type PluginLifecycleOperation = "install" | "enable" | "disable" | "upgrade" | "rollback" | "retire" | "dependency-check";
export interface PluginLifecycleInstallationResponse {
id: string;
pluginId: string;
serverInstanceId: string;
currentVersion?: string;
targetVersion?: string;
previousVersion?: string;
desiredState: string;
currentState: string;
lastOperation?: PluginLifecycleOperation;
compatibility?: string;
dependencyState?: string;
jobId?: string;
alertId?: string;
auditEventId?: string;
failureReason?: string;
createdAt: string;
updatedAt: string;
}
export interface PluginLifecycleListResponse { items: PluginLifecycleInstallationResponse[]; count: number; }
export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; confirmed: boolean; }
export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; decision: CapacityAdmissionDecisionResponse; alert?: AlertResponse; }
export interface AIConfigDiffPreviewResponse {
id: string;
requestId: string;
createdBy: string;
serverInstanceId: string;
pluginId?: string;
providerId?: string;
model?: string;
key: string;
configVersion: number;
currentConfigChecksum?: string;
proposedConfig?: string;
diffSummary: string;
state: "pending" | "approved" | "cancelled" | "expired";
expiresAt: string;
approvedBy?: string;
approvedAt?: string;
jobId?: string;
createdAt: string;
updatedAt: string;
}
export interface AIConfigDiffListResponse { items: AIConfigDiffPreviewResponse[]; count: number; }
export interface AIConfigDiffApprovalResponse { preview: AIConfigDiffPreviewResponse; dispatch: ServerConfigWriteDispatchResponse; }
export interface AIInvocationSafeErrorResponse {
code: string;
message: string;