diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index 49cc38d..19f9bf9 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -78,6 +78,13 @@ - A broader line-shape diagnostic confirmed coordinate-shaped and network-shaped tokens exist in multiple log families, so parser assets must stay per-file-pattern and strip network material before durable storage. The authoritative file inventory remains the successful v4 evidence file. - Task 3.10 remains unchecked: the refreshed content-feature baseline confirms `user_profile.template_xml` is the parseable named-attribute source candidate, but it still does not prove preserving patch semantics, offline/backup/readback requirements, activation semantics, write safety, or an operator-confirmed `855` preset mapping. +### Adapter evidence matrix and release gates (2026-08-13) + +- Matched the production SCUM live-data adapter gates to the current-service schema fingerprint `sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0` and packaged query/map asset digests. The schema-probe gate is now `enabled` / `compatible` because the product durable Run schema-probe path succeeded, while the database-backed read gates remain `disabled` / `missing` until the group 4 SQLite-template terminal envelope is implemented and accepted. +- Added manifest-validator enforcement that every declared SCUM SQLite query, typed command, guarded mutation, map asset, and gift catalog must have a matching capability gate with the same adapter version, schema fingerprint where applicable, and immutable asset digest. This prevents future assets from bypassing the release gate matrix. +- Kept unsupported or ambiguous write capabilities disabled: no typed RCON templates, no gift catalogs, no guarded XML mutations, no `855` preset, and no command/readback claims were added. Squad rank/leader, vehicle ownership, territory semantics, and sub-10s map cadence remain nullable/gated instead of invented. +- Verification passed: `(cd plugins && npm test -- manifest-validation.test.ts)`, `(cd plugins && npm run validate:manifest)`, and `(cd platform && go test ./validator ./domain)`. + - [x] 3.1 Add SDK and manifest types for versioned log parsers, SQLite query assets, parameter/result schemas, capability-specific schema fingerprints, sync cadence/limits, map metadata, typed RCON templates, gift item catalogs, and guarded mutation declarations. - [x] 3.2 Extend plugin validation to require asset digests, contained package paths, unique template keys, bounded parameters/results, compatible adapter versions, and explicit permission bindings, and to reject raw caller-supplied SQL, RCON, XML, paths, or undeclared parameters. - [x] 3.3 Capture sanitized authentic login-log fixtures from the active service and bind their expected events to server, Run binding, plugin version, parser version/digest, a transport cursor `(source identity, stream generation, sequence)`, and a separate privacy-safe logical event identity stable across rotation overlap. @@ -90,7 +97,7 @@ - [ ] 3.10 Declare a guarded preserving XML mutation only after the real XML source and named attributes are proven; expose `855` only as a reviewed named-attribute preset and never as a database column, generic integer field, or guessed mapping. - [x] 3.11 Add immutable asset/digest declarations and plugin package validation; defer generated Run-package execution wiring until the complete protocol/result envelope and independent Run capability evidence in group 4 are frozen. - [x] 3.12 Remove SCUM Workflow/projection declarations and obsolete page/action declarations from the plugin manifest while preserving the five required pages and AI configuration assistance. -- [ ] 3.13 Match the observed fingerprint/evidence matrix against each completed adapter, add per-capability compatibility/release-gate tests, and leave every unsupported or ambiguous player/squad/vehicle/flag/position/write capability disabled. +- [x] 3.13 Match the observed fingerprint/evidence matrix against each completed adapter, add per-capability compatibility/release-gate tests, and leave every unsupported or ambiguous player/squad/vehicle/flag/position/write capability disabled. ## 4. Generic External Run Execution and Result Contracts diff --git a/platform/validator/scum_live_data.go b/platform/validator/scum_live_data.go index a105054..b30bd77 100644 --- a/platform/validator/scum_live_data.go +++ b/platform/validator/scum_live_data.go @@ -154,7 +154,7 @@ func validateSCUMLiveDataManifest(prefix string, value domain.SCUMLiveDataManife if !validSCUMFingerprint(gate.RequiredSchemaFingerprint) { violations = append(violations, field+".requiredSchemaFingerprint is required when enabled") } - if len(gate.RequiredAssetDigests) == 0 { + if gate.Capability != domain.SCUMDataCapabilitySchemaProbe && len(gate.RequiredAssetDigests) == 0 { violations = append(violations, field+".requiredAssetDigests is required when enabled") } } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index e8798b6..3e6d9be 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -382,17 +382,17 @@ } }, "capabilityGates": [ - { "capability": "schema-probe", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前绑定的 Run 证明通用只读 schema probe 可用。" }, - { "capability": "players.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务 schema evidence 与版本化 adapter 匹配。" }, - { "capability": "player-details.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务玩家详情 join 与字段含义验证。" }, - { "capability": "squads.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务队伍表与 rank/leader 含义验证。" }, - { "capability": "squad-members.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务队伍成员 join 唯一性验证。" }, - { "capability": "vehicles.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务载具 identity、ownership 和坐标验证。" }, - { "capability": "flags.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务旗子 ownership 与 territory 关系验证。" }, - { "capability": "positions.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务坐标范围、cadence 和 map transform 验证。" }, - { "capability": "profile-xml.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待真实 XML 来源、字段名、backup 和离线安全证据验证。" }, - { "capability": "economy-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待 Fame/currency command 与 readback confirmation 验证。" }, - { "capability": "gift-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待 gift item catalog、transport 和 conclusive receipt 验证。" } + { "capability": "schema-probe", "gate": "enabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "evidenceStatus": "compatible", "safeReason": "当前绑定 durable schema probe 已返回匹配的 redacted schema fingerprint。" }, + { "capability": "players.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:b3359f3c5939a410a98ddfccb920791044df76da4f8e5df4e726a9fe58423520", "sha256:d9c262d0b4f1b600b608c8279a6dfb052de1a605fe1286ddc15f74892eace4fc"], "evidenceStatus": "missing", "safeReason": "schema 和 asset 已匹配;等待 SQLite-template terminal envelope 验证后启用。" }, + { "capability": "player-details.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:c980807b736f8988cd7c37ecb97c72e5e707050450ebc8c539dccec06c60e1f8", "sha256:919c5f68f74fdb0743fa5d9b59aeecd003dcdac5bc51e52d3a486ed1045c9a48"], "evidenceStatus": "missing", "safeReason": "详情和经济只读 asset 已匹配;等待 terminal envelope 验证后启用。" }, + { "capability": "squads.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:e7746ac4f4ea86038c1a035087154d4dc3a622fdb14b3ad94b45fec819486f50"], "evidenceStatus": "missing", "safeReason": "队伍只读 asset 已匹配;rank 和 leader 语义保持 nullable,等待 envelope 验证。" }, + { "capability": "squad-members.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:e49b634d8a63b0d1f979706ff4a99050c11b5661d494f72897e89da6554f0654"], "evidenceStatus": "missing", "safeReason": "成员 join asset 已匹配;等待 SQLite-template terminal envelope 验证后启用。" }, + { "capability": "vehicles.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:e2362451c3a4392adeca38c5e81055674f78e982cee710a33717b3d06bb78dc3"], "evidenceStatus": "missing", "safeReason": "载具 identity 和坐标 asset 已匹配;ownership 保持 nullable,等待 envelope 验证。" }, + { "capability": "flags.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:42ddc6e8a237b7d576834845d1b1d5b2a2fc087668579eac0e3dc08ce2941000"], "evidenceStatus": "missing", "safeReason": "旗子和 base join asset 已匹配;territory 语义保持 gated,等待 envelope 验证。" }, + { "capability": "positions.read", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "requiredSchemaFingerprint": "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0", "requiredAssetDigests": ["sha256:88629de001e6882ce9e72bf5f0b17770d408c665869d04cd768c2e3fdfb9f790", "sha256:74800836553c7e4372a0747c5e9511adcee940b057194488bbf65bf164df372b", "sha256:f1941109167a86818e9e71996821884aeb8bd7e863584454b891e525695ba478"], "evidenceStatus": "missing", "safeReason": "坐标 query 和 map transform 已匹配;等待 envelope 验证且不声明 sub-10s cadence。" }, + { "capability": "profile-xml.write", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "evidenceStatus": "missing", "safeReason": "等待 XML patch safety、backup、offline、readback 和 855 映射验证。" }, + { "capability": "economy-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "evidenceStatus": "missing", "safeReason": "等待 Fame 和 currency command 的确认读回语义验证。" }, + { "capability": "gift-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v1", "evidenceStatus": "missing", "safeReason": "等待 gift catalog、transport 和 conclusive receipt 验证。" } ], "logParsers": [ { diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index d0cbed0..dfca427 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -1123,7 +1123,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st if (gate.gate === "enabled") { if (gate.evidenceStatus !== "compatible") errors.push(`${gateLocation}.evidenceStatus: enabled gates require compatible evidence`); if (!/^(sha256:)?[a-fA-F0-9]{16,128}$/.test(gate.requiredSchemaFingerprint ?? "")) errors.push(`${gateLocation}.requiredSchemaFingerprint: enabled gates require a schema fingerprint`); - if (!Array.isArray(gate.requiredAssetDigests) || gate.requiredAssetDigests.length === 0) errors.push(`${gateLocation}.requiredAssetDigests: enabled gates require immutable asset digests`); + if (capability !== "schema-probe" && (!Array.isArray(gate.requiredAssetDigests) || gate.requiredAssetDigests.length === 0)) errors.push(`${gateLocation}.requiredAssetDigests: enabled gates require immutable asset digests`); } if (gate.gate === "disabled" && gate.evidenceStatus === "compatible") errors.push(`${gateLocation}.evidenceStatus: disabled gates must not claim compatible evidence`); } @@ -1136,6 +1136,21 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st if (!digestPattern.test(asset.digest ?? "")) errors.push(`${assetLocation}.digest: must be a sha256 digest`); if (asset.assetPath && asset.digest) validateSCUMAssetDigest(asset.assetPath, asset.digest, `${assetLocation}.digest`); }; + const gateByCapability = new Map(gates.map((gate) => [gate.capability ?? "", gate])); + const requireCapabilityGateMatch = (capability: string | undefined, adapterVersion: string | undefined, schemaFingerprint: string | undefined, digests: Array, itemLocation: string): void => { + const safeCapability = capability ?? ""; + const gate = gateByCapability.get(safeCapability); + if (!gate) { + errors.push(`${itemLocation}: capability ${safeCapability} must have a release gate`); + return; + } + if (gate.adapterVersion !== adapterVersion) errors.push(`${itemLocation}: capability gate adapterVersion must match the asset adapterVersion`); + if (schemaFingerprint && gate.requiredSchemaFingerprint !== schemaFingerprint) errors.push(`${itemLocation}: capability gate schema fingerprint must match the asset requirement`); + const gateDigests = new Set(gate.requiredAssetDigests ?? []); + for (const digest of digests) { + if (digestPattern.test(digest ?? "") && !gateDigests.has(digest ?? "")) errors.push(`${itemLocation}: capability gate must include asset digest ${digest}`); + } + }; const validateSCUMAssetDigest = (assetPath: string, expectedDigest: string, digestLocation: string): void => { if (!manifestDir || !isSafeRelativePathRef(assetPath) || !digestPattern.test(expectedDigest)) return; const absoluteAssetPath = path.resolve(manifestDir, assetPath); @@ -1265,6 +1280,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st requireAsset(query, itemLocation); if (!readCapabilities.has(query.capability ?? "")) errors.push(`${itemLocation}.capability: must be a read capability`); if (!fingerprintPattern.test(query.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`); + requireCapabilityGateMatch(query.capability, query.adapterVersion, query.requiredSchemaFingerprint, [query.digest], itemLocation); requireRefs(query as Record, itemLocation, ["parameterSchemaRef", "resultSchemaRef"]); if (!Number.isInteger(query.maxRows) || (query.maxRows ?? 0) < 1 || (query.maxRows ?? 0) > 500) errors.push(`${itemLocation}.maxRows: must be between 1 and 500`); if (!Number.isInteger(query.timeoutMs) || (query.timeoutMs ?? 0) < 1 || (query.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`); @@ -1288,6 +1304,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st requireAsset(template, itemLocation); if (!["economy-command.write", "gift-command.write"].includes(template.capability ?? "")) errors.push(`${itemLocation}.capability: must be economy-command.write or gift-command.write`); if (template.requiredSchemaFingerprint && !fingerprintPattern.test(template.requiredSchemaFingerprint)) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`); + requireCapabilityGateMatch(template.capability, template.adapterVersion, template.requiredSchemaFingerprint, [template.digest], itemLocation); if (template.permission !== "server.game-client.command" || !declaredPermissions.has("server.game-client.command")) errors.push(`${itemLocation}.permission: must require declared server.game-client.command`); requireRefs(template as Record, itemLocation, ["payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef"]); if (!Number.isInteger(template.timeoutMs) || (template.timeoutMs ?? 0) < 1 || (template.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`); @@ -1302,6 +1319,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st requireAsset(mutation, itemLocation); if (mutation.capability !== "profile-xml.write") errors.push(`${itemLocation}.capability: must be profile-xml.write`); if (!fingerprintPattern.test(mutation.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`); + requireCapabilityGateMatch(mutation.capability, mutation.adapterVersion, mutation.requiredSchemaFingerprint, [mutation.digest], itemLocation); if (mutation.permission !== "server.game-client.maintenance" || !declaredPermissions.has("server.game-client.maintenance")) errors.push(`${itemLocation}.permission: must require declared server.game-client.maintenance`); requireRefs(mutation as Record, itemLocation, ["payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef"]); if (!Number.isInteger(mutation.timeoutMs) || (mutation.timeoutMs ?? 0) < 1 || (mutation.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`); @@ -1317,6 +1335,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st const itemLocation = `${location}.mapAssets[${index}]`; requireAsset(mapAsset, itemLocation); if (!fingerprintPattern.test(mapAsset.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`); + requireCapabilityGateMatch("positions.read", mapAsset.adapterVersion, mapAsset.requiredSchemaFingerprint, [mapAsset.digest, mapAsset.transformDigest], itemLocation); requireRefs(mapAsset as Record, itemLocation, ["metadataSchemaRef"]); if (!mapAsset.transformAssetPath || !isSafeRelativePathRef(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be a contained package-relative path`); if (mapAsset.transformAssetPath && !assetFiles.has(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be declared in manifest.assetFiles`); @@ -1332,6 +1351,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st const itemLocation = `${location}.giftCatalogs[${index}]`; requireAsset(catalog, itemLocation); if (!adapterPattern.test(catalog.catalogVersion ?? "")) errors.push(`${itemLocation}.catalogVersion: must be a bounded catalog version`); + requireCapabilityGateMatch("gift-command.write", catalog.adapterVersion, undefined, [catalog.digest], itemLocation); requireRefs(catalog as Record, itemLocation, ["itemSchemaRef"]); if (!Array.isArray(catalog.transportTemplateKeys) || catalog.transportTemplateKeys.length === 0) errors.push(`${itemLocation}.transportTemplateKeys: must reference at least one typed RCON transport template`); for (const templateKey of catalog.transportTemplateKeys ?? []) { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index bac9dde..ac6ae06 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -294,14 +294,31 @@ describe("plugin manifest validation", () => { expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(false); }); - it("keeps SCUM database-backed live-data gates disabled until current-service evidence exists", () => { + it("matches SCUM live-data gates to the current-service adapter matrix", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration; remoteAccess: { runCapabilities: string[] } }; expect(manifest.capabilities).toContain("remote.run.db.sqlite.probe"); expect(manifest.remoteAccess.runCapabilities).toContain("remote.run.db.sqlite.probe"); expect(manifest.scumLiveData.probe).toMatchObject({ capability: "remote.run.db.sqlite.probe", targetKey: "scum-database" }); expect(manifest.runtimeProfiles?.dataTargets?.find((target) => target.key === "scum-database")).toMatchObject({ kind: "sqlite.snapshot", transportKey: "scum-database", sourceRootKey: "server-root", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" }); - expect(manifest.scumLiveData.capabilityGates.map((gate) => gate.capability)).toEqual(expect.arrayContaining(["players.read", "squads.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"])); - expect(manifest.scumLiveData.capabilityGates.every((gate) => gate.gate === "disabled" && gate.evidenceStatus === "missing")).toBe(true); + const gates = new Map(manifest.scumLiveData.capabilityGates.map((gate) => [gate.capability, gate])); + expect(gates.get("schema-probe")).toMatchObject({ gate: "enabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, evidenceStatus: "compatible" }); + + const queryDigestsByCapability = new Map(); + for (const query of manifest.scumLiveData.sqliteQueries ?? []) { + const digests = queryDigestsByCapability.get(query.capability) ?? []; + digests.push(query.digest); + queryDigestsByCapability.set(query.capability, digests); + } + const mapAsset = manifest.scumLiveData.mapAssets?.[0]; + if (mapAsset) queryDigestsByCapability.set("positions.read", [...(queryDigestsByCapability.get("positions.read") ?? []), mapAsset.digest, mapAsset.transformDigest]); + + for (const [capability, digests] of queryDigestsByCapability) { + const gate = gates.get(capability as any); + expect(gate).toMatchObject({ gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: currentSCUMSchemaFingerprint, evidenceStatus: "missing" }); + for (const digest of digests) expect(gate?.requiredAssetDigests).toContain(digest); + } + expect([...gates.keys()]).toEqual(expect.arrayContaining(["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"])); + for (const capability of ["profile-xml.write", "economy-command.write", "gift-command.write"] as const) expect(gates.get(capability)).toMatchObject({ gate: "disabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing" }); expect(JSON.stringify(manifest.scumLiveData).toLowerCase()).not.toMatch(/select\s+.+from|sqlite:\/\/|mysql:\/\/|password|credential|socket|hostpath/); }); @@ -483,7 +500,7 @@ describe("plugin manifest validation", () => { it("rejects enabling SCUM live-data gates without compatible evidence and immutable digests", () => { const errors = validateTemporaryScumCompanionManifest((manifest) => { - manifest.scumLiveData.capabilityGates[1] = { ...manifest.scumLiveData.capabilityGates[1], gate: "enabled", evidenceStatus: "missing" }; + manifest.scumLiveData.capabilityGates[1] = { capability: "players.read", gate: "enabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing", safeReason: "waiting for current service evidence" }; }); expect(errors.some((error) => error.includes("enabled gates require compatible evidence"))).toBe(true); expect(errors.some((error) => error.includes("enabled gates require a schema fingerprint"))).toBe(true); @@ -503,21 +520,33 @@ describe("plugin manifest validation", () => { ]; const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => { manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))]; - for (const assetPath of [assetPaths[0], assetPaths[1], assetPaths[2], assetPaths[3], assetPaths[6]]) writeFixtureJSON(fixtureDir, assetPath, { packaged: true }); + for (const assetPath of [assetPaths[0], assetPaths[1], assetPaths[2], assetPaths[3], assetPaths[6]]) writeFixtureJSON(fixtureDir, assetPath, { packaged: true, assetPath }); const mapBounds = { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }; const mapImage = { width: 4096, height: 4096 }; writeFixtureJSON(fixtureDir, assetPaths[4], { key: "island-map", mapVersion: "island-map-v1", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, sourceEvidence: "fixture", authorization: { redistribution: "first-party-generated-coordinate-metadata", baseMapArtwork: "not-packaged", renderingAvailability: "unavailable-until-authorized-base-map" }, worldBounds: mapBounds, image: mapImage, layers: [{ key: "players", capability: "positions.read", subjectType: "player", label: "Players", sourceQueryKey: "scum-positions-read" }], transformAssetPath: assetPaths[5] }); writeFixtureJSON(fixtureDir, assetPaths[5], { key: "island-transform", transformVersion: "island-transform-v1", mapAssetKey: "island-map", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, worldBounds: mapBounds, image: mapImage, validation: { rejectNonFinite: true, rejectOutOfBounds: true, acceptBoundaryPoints: true }, fixtures: [{ name: "origin", world: { x: 0, y: 0 }, pixel: { x: 2047.5, y: 2047.5 } }] }); const digest = (assetPath: string) => sha256FixtureDigest(fixtureDir, assetPath); + const giftGrantDigest = digest(assetPaths[2]); + const mutationDigest = digest(assetPaths[3]); + const mapDigest = digest(assetPaths[4]); + const transformDigest = digest(assetPaths[5]); + const catalogDigest = digest(assetPaths[6]); manifest.scumLiveData = { ...manifest.scumLiveData, + capabilityGates: [ + { capability: "schema-probe", gate: "disabled", adapterVersion: "scum-live-data-v1", evidenceStatus: "missing", safeReason: "waiting for current service evidence" }, + { capability: "players.read", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [digest(assetPaths[1])], evidenceStatus: "missing", safeReason: "waiting for query execution evidence" }, + { capability: "positions.read", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [mapDigest, transformDigest], evidenceStatus: "missing", safeReason: "waiting for map execution evidence" }, + { capability: "gift-command.write", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [giftGrantDigest, catalogDigest], evidenceStatus: "missing", safeReason: "waiting for gift command evidence" }, + { capability: "profile-xml.write", gate: "disabled", adapterVersion: "scum-live-data-v1", requiredSchemaFingerprint: fingerprint, requiredAssetDigests: [mutationDigest], evidenceStatus: "missing", safeReason: "waiting for mutation safety evidence" } + ], logParsers: [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[0], digest: digest(assetPaths[0]), parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }], sqliteQueries: [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[1], digest: digest(assetPaths[1]), capability: "players.read", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 100, timeoutMs: 5000, maxResultBytes: 65536 }], syncCadences: [{ capability: "players.read", intervalSeconds: 300, jitterPercent: 20, timeoutMs: 5000, maxConcurrentPerServer: 1 }], - typedRconTemplates: [{ key: "gift-grant", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[2], digest: digest(assetPaths[2]), capability: "gift-command.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/gift-grant.payload.schema.json", resultSchemaRef: "schemas/scum-live/gift-grant.result.schema.json", confirmationSchemaRef: "schemas/scum-live/gift-grant.confirmation.schema.json", timeoutMs: 5000, maxPayloadBytes: 4096 }], - guardedMutations: [{ key: "profile-xml-patch", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[3], digest: digest(assetPaths[3]), capability: "profile-xml.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", permission: "server.game-client.maintenance", payloadSchemaRef: "schemas/scum-live/profile-xml-patch.payload.schema.json", resultSchemaRef: "schemas/scum-live/profile-xml-patch.result.schema.json", confirmationSchemaRef: "schemas/scum-live/profile-xml-patch.confirmation.schema.json", timeoutMs: 10000, maxPayloadBytes: 8192, maxRowsAffected: 1, safety: { requiresExpectedChecksum: true, requiresBackupEvidence: true, requiresOfflineOrMaintenance: true, requiresReadAfterWrite: true } }], - mapAssets: [{ key: "island-map", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[4], digest: digest(assetPaths[4]), requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest: digest(assetPaths[5]), worldBounds: { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }, image: { width: 4096, height: 4096 } }], - giftCatalogs: [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[6], digest: digest(assetPaths[6]), catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }] + typedRconTemplates: [{ key: "gift-grant", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[2], digest: giftGrantDigest, capability: "gift-command.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/gift-grant.payload.schema.json", resultSchemaRef: "schemas/scum-live/gift-grant.result.schema.json", confirmationSchemaRef: "schemas/scum-live/gift-grant.confirmation.schema.json", timeoutMs: 5000, maxPayloadBytes: 4096 }], + guardedMutations: [{ key: "profile-xml-patch", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[3], digest: mutationDigest, capability: "profile-xml.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", permission: "server.game-client.maintenance", payloadSchemaRef: "schemas/scum-live/profile-xml-patch.payload.schema.json", resultSchemaRef: "schemas/scum-live/profile-xml-patch.result.schema.json", confirmationSchemaRef: "schemas/scum-live/profile-xml-patch.confirmation.schema.json", timeoutMs: 10000, maxPayloadBytes: 8192, maxRowsAffected: 1, safety: { requiresExpectedChecksum: true, requiresBackupEvidence: true, requiresOfflineOrMaintenance: true, requiresReadAfterWrite: true } }], + mapAssets: [{ key: "island-map", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[4], digest: mapDigest, requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest, worldBounds: { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }, image: { width: 4096, height: 4096 } }], + giftCatalogs: [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[6], digest: catalogDigest, catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }] }; }); expect(errors).toEqual([]);