feat(scum): add read-only migration flags
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Verified implementation blockers
|
||||
|
||||
Checked 2026-07-29 against the pinned read-only UE4SS reference at
|
||||
`bae91527355f14faa63c1df65f742cc48594ba1b` (UE4SS 3.0.1). The accompanying
|
||||
capability evidence is recorded in
|
||||
`plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md`.
|
||||
|
||||
The reference offers only a fixed, online-recipient `SendChat` path, which is
|
||||
already bounded by the supported `player.notify` adapter. It has no versioned
|
||||
server-side schema, identity binding, acknowledgement contract, or isolated
|
||||
non-production fixture for login/logout events, network correlation, position
|
||||
or vehicle events, reward delivery, or state reads and writes.
|
||||
|
||||
Consequently these tasks remain open and blocked rather than simulated:
|
||||
|
||||
- 4.1–4.4: no legitimate versioned event producer exists for semantic player,
|
||||
network, position, or vehicle data.
|
||||
- 5.1 and 5.3: no documented, version-bound state-patch or reward-delivery
|
||||
adapter exists.
|
||||
- 5.5 and 6.2: the required adapter coverage and isolated non-production
|
||||
fixtures do not exist.
|
||||
- 6.3: transitional SCUM APIs and models still have callers and cannot be
|
||||
removed before the parity and rollback evidence required by 6.2.
|
||||
- 6.4: final full verification is deferred until the blocked adapters and
|
||||
isolated integration environment exist.
|
||||
|
||||
No fallback to raw RCON, credentials, SQL, direct game-database access, OCR,
|
||||
screenshots, keyboard/mouse injection, or desktop automation is permitted.
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## 6. Migrate transitional platform behavior safely
|
||||
|
||||
- [ ] 6.1 Introduce feature/version flags and read-only migration adapters so existing platform records remain visible with provenance while plugin-owned records become authoritative per server.
|
||||
- [x] 6.1 Introduce feature/version flags and read-only migration adapters so existing platform records remain visible with provenance while plugin-owned records become authoritative per server.
|
||||
- [ ] 6.2 Verify plugin parity for configuration, player history, gifts, state-patch audits, and trajectories against controlled fixtures and an isolated Companion integration environment.
|
||||
- [ ] 6.3 Remove SCUM-named platform APIs, models, services, routes, and frontend components only after no callers remain and migration/rollback evidence is recorded.
|
||||
- [ ] 6.4 Run full platform, plugin, frontend, manifest, OpenSpec strict, structure, and isolated end-to-end verification; commit and push only the scoped migration files.
|
||||
|
||||
@@ -3,7 +3,9 @@ export type SCUMFeatureKey = (typeof scumFeatureKeys)[number];
|
||||
|
||||
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string; serverVersion?: string };
|
||||
export type SCUMMigrationProvenance = "plugin" | "transitional-read-only";
|
||||
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; payload: T; recordedAt: string; sourceRecordId?: string };
|
||||
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string };
|
||||
export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; serverVersion: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string };
|
||||
export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string };
|
||||
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
|
||||
|
||||
export type SCUMConfigField = {
|
||||
@@ -24,7 +26,7 @@ export type SCUMGiftGrant = { id: string; revisionId: string; playerId: string;
|
||||
|
||||
export type SCUMStateField = { key: string; label: string; value: number; minimum: number; maximum: number; editable: boolean; reason?: string };
|
||||
export type SCUMStateSnapshot = { playerId: string; gameVersion: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string };
|
||||
export type SCUMStatePatch = { id: string; playerId: string; gameVersion: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported"; createdAt: string };
|
||||
export type SCUMStatePatch = { id: string; playerId: string; gameVersion: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported" | "unknown"; createdAt: string };
|
||||
|
||||
export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subjectType: "player" | "vehicle"; x: number; y: number; z?: number; source: string };
|
||||
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
|
||||
|
||||
@@ -1,19 +1,71 @@
|
||||
import type { SCUMMigrationRecord, SCUMPlayer, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js";
|
||||
import type { SCUMConfigRead, SCUMFeatureKey, SCUMFeatureMigrationAuthority, SCUMFeatureMigrationStatus, SCUMGiftGrant, SCUMMigrationRecord, SCUMPlayer, SCUMPlayerProfile, SCUMPlayerRisk, SCUMPlayerSession, SCUMStatePatch, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js";
|
||||
|
||||
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", payload, recordedAt, sourceRecordId }; }
|
||||
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", payload, recordedAt }; }
|
||||
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; }
|
||||
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", readOnly: false, payload, recordedAt }; }
|
||||
|
||||
// The authority flag is exact-server and exact-version. Missing, duplicate, or
|
||||
// transitional flags fail closed: history remains readable, but plugin writes
|
||||
// are not enabled. Execution still additionally requires Companion feature
|
||||
// availability; this flag never authorizes a command by itself.
|
||||
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, serverVersion: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
|
||||
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.serverVersion === serverVersion && flag.feature === feature);
|
||||
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器版本尚未启用插件权威记录。" };
|
||||
const flag = matches[0];
|
||||
if (flag.authority !== "plugin") return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: flag.reason ?? "过渡记录仅供只读查看。" };
|
||||
return { authority: "plugin", readOnlyHistory: true, pluginWritesEnabled: true, reason: flag.reason };
|
||||
}
|
||||
|
||||
export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayer> | null {
|
||||
const id = text(record.id); const gamePlayerId = text(record.gamePlayerId); const displayName = text(record.displayName); if (!id || !gamePlayerId || !displayName) return null;
|
||||
return transitionalReadOnly({ id, gamePlayerId, displayName, lastSeenAt: optionalText(record.lastSeenAt), status: record.online === true ? "online" : "unknown" }, optionalText(record.updatedAt) ?? new Date(0).toISOString(), id);
|
||||
}
|
||||
|
||||
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
|
||||
const version = text(record.version) ?? text(record.gameVersion); const fields = stringFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!version || !fields || !observedAt) return null;
|
||||
return transitionalReadOnly({ version, fields, observedAt }, observedAt, text(record.id));
|
||||
}
|
||||
|
||||
export function migratePlayerProfileRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayerProfile> | null {
|
||||
const player = migratePlayerRecord(object(record.player) ?? record); if (!player) return null;
|
||||
const sessions = array(record.sessions).map(migrateSession).filter((item): item is SCUMPlayerSession => item !== null);
|
||||
const risks = [...array(record.accessAttempts), ...array(record.securitySignals)].map(migrateRisk).filter((item): item is SCUMPlayerRisk => item !== null);
|
||||
const recordedAt = timestamp(record.updatedAt) ?? player.recordedAt;
|
||||
return transitionalReadOnly({ player: player.payload, sessions, risks }, recordedAt, player.sourceRecordId);
|
||||
}
|
||||
|
||||
export function migrateGiftGrantRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMGiftGrant> | null {
|
||||
const id = text(record.id); const revisionId = text(record.revisionId); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const status = giftStatus(record.status); const createdAt = timestamp(record.createdAt); if (!id || !revisionId || !playerId || !status || !createdAt) return null;
|
||||
const completedAt = timestamp(record.completedAt); const notice = optionalText(record.notice) ?? "";
|
||||
return transitionalReadOnly({ id, revisionId, playerId, notice, status, createdAt, ...(completedAt ? { completedAt } : {}) }, timestamp(record.updatedAt) ?? createdAt, id);
|
||||
}
|
||||
|
||||
export function migrateStatePatchRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMStatePatch> | null {
|
||||
const id = text(record.id); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const gameVersion = text(record.gameVersion); const expectedStateVersion = text(record.expectedStateVersion); const safetyWindow = text(record.safetyWindow); const reason = optionalText(record.reason) ?? ""; const status = stateStatus(record.status); const createdAt = timestamp(record.createdAt); const changes = array(record.changes).map(migrateStateChange).filter((item): item is { fieldKey: string; before: number; after: number } => item !== null);
|
||||
if (!id || !playerId || !gameVersion || !expectedStateVersion || !safetyWindow || !status || !createdAt || !changes.length) return null;
|
||||
return transitionalReadOnly({ id, playerId, gameVersion, expectedStateVersion, safetyWindow, reason, changes, status, createdAt }, timestamp(record.updatedAt) ?? createdAt, id);
|
||||
}
|
||||
|
||||
export function migrateTrajectoryRecord(record: Record<string, unknown>): SCUMTrajectory | null {
|
||||
const subjectId = text(record.playerRecordId) ?? text(record.vehicleId); const subjectType = text(record.playerRecordId) ? "player" : "vehicle"; const points = Array.isArray(record.points) ? record.points.map(migratePoint).filter((point): point is SCUMTrajectoryPoint => point !== null) : [];
|
||||
return subjectId && points.length ? { subjectId, subjectType, points, provenance: "transitional-read-only" } : null;
|
||||
}
|
||||
|
||||
export function migrateTrajectoryHistoryRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMTrajectory> | null {
|
||||
const trajectory = migrateTrajectoryRecord(record); if (!trajectory) return null;
|
||||
const recordedAt = timestamp(record.updatedAt) ?? trajectory.points[trajectory.points.length - 1].occurredAt;
|
||||
return transitionalReadOnly(trajectory, recordedAt, text(record.id));
|
||||
}
|
||||
|
||||
function migratePoint(value: unknown): SCUMTrajectoryPoint | null { if (!value || typeof value !== "object") return null; const record = value as Record<string, unknown>; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = text(record.recordedAt) ?? text(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; return { occurredAt, subjectId: text(record.playerRecordId) ?? text(record.vehicleId) ?? "unknown", subjectType: text(record.playerRecordId) ? "player" : "vehicle", x, y, z: number(record.worldZ), source: "transitional-read-only" }; }
|
||||
function migrateSession(value: unknown): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId)); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
|
||||
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
|
||||
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
|
||||
function stringFields(value: unknown): Record<string, string> | null { const fields = object(value); if (!fields) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (!text(key) || !(typeof field === "string" || typeof field === "number" || typeof field === "boolean")) return null; result[key] = String(field); } return result; }
|
||||
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
|
||||
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown") return value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "execution-unknown" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
|
||||
function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
|
||||
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
|
||||
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
||||
function optionalText(value: unknown): string | undefined { return text(value); }
|
||||
function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
|
||||
function timestamp(value: unknown): string | undefined { const candidate = text(value); return candidate && !Number.isNaN(Date.parse(candidate)) ? candidate : undefined; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { migratePlayerRecord, migrateTrajectoryRecord } from "../examples/scum-server-plugin/features/migration.js";
|
||||
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
|
||||
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
|
||||
import { configurationCatalog, validateConfigPatch, validateStatePatch } from "../examples/scum-server-plugin/features/schemas.js";
|
||||
|
||||
@@ -13,10 +13,25 @@ describe("SCUM plugin feature module", () => {
|
||||
});
|
||||
|
||||
it("maps transitional records only as read-only provenance", () => {
|
||||
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", payload: { gamePlayerId: "steam-1" } });
|
||||
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", readOnly: true, payload: { gamePlayerId: "steam-1" } });
|
||||
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
|
||||
});
|
||||
|
||||
it("preserves only allowlisted transitional history for every feature area", () => {
|
||||
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
|
||||
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
|
||||
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
|
||||
expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", gameVersion: "0.9.700.90357", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true });
|
||||
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
|
||||
});
|
||||
|
||||
it("enables plugin authority only for one exact server-version feature flag", () => {
|
||||
const flags = [{ serverInstanceId: "server-1", serverVersion: "0.9.700.90357", feature: "configuration" as const, authority: "plugin" as const }];
|
||||
expect(migrationStatus(flags, "server-1", "0.9.700.90357", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });
|
||||
expect(migrationStatus(flags, "server-2", "0.9.700.90357", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
expect(migrationStatus([...flags, flags[0]], "server-1", "0.9.700.90357", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
});
|
||||
|
||||
it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => {
|
||||
const nodes: string[] = []; const buttons = new Map<string, boolean>();
|
||||
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
|
||||
|
||||
Reference in New Issue
Block a user