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 e1e7d71..986d4b2 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 @@ -19,8 +19,8 @@ ## 3. Plugin SDK, Manifest, and Immutable SCUM Assets -- [ ] 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. -- [ ] 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.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. - [ ] 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. - [ ] 3.4 Implement the versioned SCUM login/logout parser and tests for successful login/logout, failed login, partial/undecodable/oversized/malformed lines, copy-truncate/rotation overlap under a new generation, Run restart/resume, duplicate delivery, and out-of-order delivery while discarding IP/network material before storage or logical fingerprinting. - [ ] 3.5 Add parameterized, read-only player identity/detail/economy/session-enrichment query assets and exact result schemas only for joins and fields proven by the probe. diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 81aa9c8..237885f 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -295,7 +295,14 @@ "properties": { "schemaVersion": { "const": "1" }, "probe": { "$ref": "#/$defs/scumSchemaProbeDeclaration" }, - "capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/scumLiveDataCapabilityGate" }, "uniqueItems": true, "minItems": 1, "maxItems": 32 } + "capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/scumLiveDataCapabilityGate" }, "uniqueItems": true, "minItems": 1, "maxItems": 32 }, + "logParsers": { "type": "array", "items": { "$ref": "#/$defs/scumLogParserAsset" }, "uniqueItems": true, "maxItems": 16 }, + "sqliteQueries": { "type": "array", "items": { "$ref": "#/$defs/scumSQLiteQueryAsset" }, "uniqueItems": true, "maxItems": 64 }, + "syncCadences": { "type": "array", "items": { "$ref": "#/$defs/scumSyncCadence" }, "uniqueItems": true, "maxItems": 16 }, + "typedRconTemplates": { "type": "array", "items": { "$ref": "#/$defs/scumTypedRconTemplate" }, "uniqueItems": true, "maxItems": 32 }, + "guardedMutations": { "type": "array", "items": { "$ref": "#/$defs/scumGuardedMutation" }, "uniqueItems": true, "maxItems": 16 }, + "mapAssets": { "type": "array", "items": { "$ref": "#/$defs/scumMapAsset" }, "uniqueItems": true, "maxItems": 8 }, + "giftCatalogs": { "type": "array", "items": { "$ref": "#/$defs/scumGiftCatalog" }, "uniqueItems": true, "maxItems": 16 } } }, "scumSchemaProbeDeclaration": { @@ -330,6 +337,52 @@ "safeReason": { "type": "string", "minLength": 1, "maxLength": 240 } } }, + "scumAssetDigest": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, + "scumSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" }, + "scumAdapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" }, + "scumVersionedAssetBase": { + "type": "object", + "required": ["key", "adapterVersion", "assetPath", "digest"], + "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" } + } + }, + "scumReadCapability": { "enum": ["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"] }, + "scumWriteCapability": { "enum": ["profile-xml.write", "economy-command.write", "gift-command.write"] }, + "scumLogParserAsset": { + "allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["parserVersion", "sourceKey", "eventType", "eventSchemaRef", "maxLineBytes", "cursorPolicy", "privacy"], "additionalProperties": false, "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "parserVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "sourceKey": { "$ref": "#/$defs/logicalKey" }, "eventType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, "eventSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxLineBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "cursorPolicy": { "const": "source-generation-sequence" }, "privacy": { "type": "object", "required": ["stripNetworkIdentifiers", "logicalEventIdentity"], "additionalProperties": false, "properties": { "stripNetworkIdentifiers": { "const": true }, "logicalEventIdentity": { "const": "native-or-sanitized-fields" } } } + } }] + }, + "scumSQLiteQueryAsset": { + "allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutMs", "maxResultBytes"], "additionalProperties": false, "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "capability": { "$ref": "#/$defs/scumReadCapability" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 } + } }] + }, + "scumSyncCadence": { + "type": "object", "required": ["capability", "intervalSeconds", "jitterPercent", "timeoutMs", "maxConcurrentPerServer"], "additionalProperties": false, + "properties": { "capability": { "$ref": "#/$defs/scumReadCapability" }, "intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 86400 }, "jitterPercent": { "type": "integer", "minimum": 0, "maximum": 100 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxConcurrentPerServer": { "type": "integer", "minimum": 1, "maximum": 16 } } + }, + "scumTypedRconTemplate": { + "allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["capability", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "timeoutMs", "maxPayloadBytes"], "additionalProperties": false, "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "capability": { "enum": ["economy-command.write", "gift-command.write"] }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.command" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 } + } }] + }, + "scumGuardedMutation": { + "allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef", "timeoutMs", "maxPayloadBytes", "maxRowsAffected", "safety"], "additionalProperties": false, "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "capability": { "const": "profile-xml.write" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.maintenance" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "maxRowsAffected": { "const": 1 }, "safety": { "type": "object", "required": ["requiresExpectedChecksum", "requiresBackupEvidence", "requiresOfflineOrMaintenance", "requiresReadAfterWrite"], "additionalProperties": false, "properties": { "requiresExpectedChecksum": { "const": true }, "requiresBackupEvidence": { "const": true }, "requiresOfflineOrMaintenance": { "const": true }, "requiresReadAfterWrite": { "const": true } } } + } }] + }, + "scumMapAsset": { + "allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["requiredSchemaFingerprint", "metadataSchemaRef", "transformAssetPath", "transformDigest", "worldBounds", "image"], "additionalProperties": false, "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/scumSchemaFingerprint" }, "metadataSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transformAssetPath": { "$ref": "#/$defs/relativePathRef" }, "transformDigest": { "$ref": "#/$defs/scumAssetDigest" }, "worldBounds": { "type": "object", "required": ["minX", "minY", "maxX", "maxY"], "additionalProperties": false, "properties": { "minX": { "type": "number" }, "minY": { "type": "number" }, "maxX": { "type": "number" }, "maxY": { "type": "number" } } }, "image": { "type": "object", "required": ["width", "height"], "additionalProperties": false, "properties": { "width": { "type": "integer", "minimum": 1, "maximum": 100000 }, "height": { "type": "integer", "minimum": 1, "maximum": 100000 } } } + } }] + }, + "scumGiftCatalog": { + "allOf": [{ "$ref": "#/$defs/scumVersionedAssetBase" }, { "type": "object", "required": ["catalogVersion", "itemSchemaRef", "transportTemplateKeys"], "additionalProperties": false, "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/scumAssetDigest" }, "catalogVersion": { "$ref": "#/$defs/scumAdapterVersion" }, "itemSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transportTemplateKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true, "minItems": 1, "maxItems": 16 } + } }] + }, "gameClientBridgeCompanion": { "type": "object", "required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"], diff --git a/plugins/schemas/scum-live-data.schema.json b/plugins/schemas/scum-live-data.schema.json index 3a26117..177ee94 100644 --- a/plugins/schemas/scum-live-data.schema.json +++ b/plugins/schemas/scum-live-data.schema.json @@ -17,9 +17,23 @@ "bounds": { "$ref": "#/$defs/probeBounds" } } }, - "capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/capabilityGate" }, "minItems": 1, "maxItems": 32 } + "capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/capabilityGate" }, "minItems": 1, "maxItems": 32 }, + "logParsers": { "type": "array", "items": { "$ref": "#/$defs/logParserAsset" }, "uniqueItems": true, "maxItems": 16 }, + "sqliteQueries": { "type": "array", "items": { "$ref": "#/$defs/sqliteQueryAsset" }, "uniqueItems": true, "maxItems": 64 }, + "syncCadences": { "type": "array", "items": { "$ref": "#/$defs/syncCadence" }, "uniqueItems": true, "maxItems": 16 }, + "typedRconTemplates": { "type": "array", "items": { "$ref": "#/$defs/typedRconTemplate" }, "uniqueItems": true, "maxItems": 32 }, + "guardedMutations": { "type": "array", "items": { "$ref": "#/$defs/guardedMutation" }, "uniqueItems": true, "maxItems": 16 }, + "mapAssets": { "type": "array", "items": { "$ref": "#/$defs/mapAsset" }, "uniqueItems": true, "maxItems": 8 }, + "giftCatalogs": { "type": "array", "items": { "$ref": "#/$defs/giftCatalog" }, "uniqueItems": true, "maxItems": 16 } }, "$defs": { + "logicalKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$", "maxLength": 120 }, + "relativePathRef": { "type": "string", "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$", "maxLength": 160 }, + "relativeJsonRef": { "type": "string", "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$" }, + "assetDigest": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, + "schemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" }, + "adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" }, + "readCapability": { "enum": ["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"] }, "probeBounds": { "type": "object", "required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"], @@ -41,6 +55,33 @@ "evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] }, "safeReason": { "type": "string", "minLength": 1, "maxLength": 240 } } + }, + "assetBase": { + "type": "object", + "required": ["key", "adapterVersion", "assetPath", "digest"], + "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" } } + }, + "logParserAsset": { + "allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["parserVersion", "sourceKey", "eventType", "eventSchemaRef", "maxLineBytes", "cursorPolicy", "privacy"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "parserVersion": { "$ref": "#/$defs/adapterVersion" }, "sourceKey": { "$ref": "#/$defs/logicalKey" }, "eventType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, "eventSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxLineBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "cursorPolicy": { "const": "source-generation-sequence" }, "privacy": { "type": "object", "required": ["stripNetworkIdentifiers", "logicalEventIdentity"], "additionalProperties": false, "properties": { "stripNetworkIdentifiers": { "const": true }, "logicalEventIdentity": { "const": "native-or-sanitized-fields" } } } } }] + }, + "sqliteQueryAsset": { + "allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutMs", "maxResultBytes"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "capability": { "$ref": "#/$defs/readCapability" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 } } }] + }, + "syncCadence": { + "type": "object", "required": ["capability", "intervalSeconds", "jitterPercent", "timeoutMs", "maxConcurrentPerServer"], "additionalProperties": false, + "properties": { "capability": { "$ref": "#/$defs/readCapability" }, "intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 86400 }, "jitterPercent": { "type": "integer", "minimum": 0, "maximum": 100 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxConcurrentPerServer": { "type": "integer", "minimum": 1, "maximum": 16 } } + }, + "typedRconTemplate": { + "allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["capability", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "timeoutMs", "maxPayloadBytes"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "capability": { "enum": ["economy-command.write", "gift-command.write"] }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.command" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 } } }] + }, + "guardedMutation": { + "allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["capability", "requiredSchemaFingerprint", "transportKey", "targetKey", "permission", "payloadSchemaRef", "resultSchemaRef", "confirmationSchemaRef", "timeoutMs", "maxPayloadBytes", "maxRowsAffected", "safety"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "capability": { "const": "profile-xml.write" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "permission": { "const": "server.game-client.maintenance" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, "maxRowsAffected": { "const": 1 }, "safety": { "type": "object", "required": ["requiresExpectedChecksum", "requiresBackupEvidence", "requiresOfflineOrMaintenance", "requiresReadAfterWrite"], "additionalProperties": false, "properties": { "requiresExpectedChecksum": { "const": true }, "requiresBackupEvidence": { "const": true }, "requiresOfflineOrMaintenance": { "const": true }, "requiresReadAfterWrite": { "const": true } } } } }] + }, + "mapAsset": { + "allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["requiredSchemaFingerprint", "metadataSchemaRef", "transformAssetPath", "transformDigest", "worldBounds", "image"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "requiredSchemaFingerprint": { "$ref": "#/$defs/schemaFingerprint" }, "metadataSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transformAssetPath": { "$ref": "#/$defs/relativePathRef" }, "transformDigest": { "$ref": "#/$defs/assetDigest" }, "worldBounds": { "type": "object", "required": ["minX", "minY", "maxX", "maxY"], "additionalProperties": false, "properties": { "minX": { "type": "number" }, "minY": { "type": "number" }, "maxX": { "type": "number" }, "maxY": { "type": "number" } } }, "image": { "type": "object", "required": ["width", "height"], "additionalProperties": false, "properties": { "width": { "type": "integer", "minimum": 1, "maximum": 100000 }, "height": { "type": "integer", "minimum": 1, "maximum": 100000 } } } } }] + }, + "giftCatalog": { + "allOf": [{ "$ref": "#/$defs/assetBase" }, { "type": "object", "required": ["catalogVersion", "itemSchemaRef", "transportTemplateKeys"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "adapterVersion": { "$ref": "#/$defs/adapterVersion" }, "assetPath": { "$ref": "#/$defs/relativePathRef" }, "digest": { "$ref": "#/$defs/assetDigest" }, "catalogVersion": { "$ref": "#/$defs/adapterVersion" }, "itemSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "transportTemplateKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true, "minItems": 1, "maxItems": 16 } } }] } } } diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 7f461d5..6dc1973 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -1059,19 +1059,41 @@ export function validateSCUMLiveDataManifest(manifest: unknown): string[] { type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string }; type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } }; type Transport = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] }; - const declaration = manifest as { id?: string; capabilities?: string[]; remoteAccess?: { runCapabilities?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[] }; scumLiveData?: { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[] } }; + type RuntimeLogSource = { key?: string }; + type AssetFile = { path?: string }; + type Asset = { key?: string; adapterVersion?: string; assetPath?: string; digest?: string }; + type LogParser = Asset & { parserVersion?: string; sourceKey?: string; eventType?: string; eventSchemaRef?: string; maxLineBytes?: number; cursorPolicy?: string; privacy?: { stripNetworkIdentifiers?: boolean; logicalEventIdentity?: string } }; + type SQLiteQuery = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; parameterSchemaRef?: string; resultSchemaRef?: string; maxRows?: number; timeoutMs?: number; maxResultBytes?: number }; + type SyncCadence = { capability?: string; intervalSeconds?: number; jitterPercent?: number; timeoutMs?: number; maxConcurrentPerServer?: number }; + type TypedRCON = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; permission?: string; payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string; timeoutMs?: number; maxPayloadBytes?: number }; + type GuardedMutation = Asset & { capability?: string; requiredSchemaFingerprint?: string; transportKey?: string; targetKey?: string; permission?: string; payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string; timeoutMs?: number; maxPayloadBytes?: number; maxRowsAffected?: number; safety?: { requiresExpectedChecksum?: boolean; requiresBackupEvidence?: boolean; requiresOfflineOrMaintenance?: boolean; requiresReadAfterWrite?: boolean } }; + type MapAsset = Asset & { requiredSchemaFingerprint?: string; metadataSchemaRef?: string; transformAssetPath?: string; transformDigest?: string; worldBounds?: { minX?: number; minY?: number; maxX?: number; maxY?: number }; image?: { width?: number; height?: number } }; + type GiftCatalog = Asset & { catalogVersion?: string; itemSchemaRef?: string; transportTemplateKeys?: string[] }; + type LiveData = { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[]; logParsers?: LogParser[]; sqliteQueries?: SQLiteQuery[]; syncCadences?: SyncCadence[]; typedRconTemplates?: TypedRCON[]; guardedMutations?: GuardedMutation[]; mapAssets?: MapAsset[]; giftCatalogs?: GiftCatalog[] }; + const declaration = manifest as { id?: string; capabilities?: string[]; permissions?: string[]; assetFiles?: AssetFile[]; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[]; logSources?: RuntimeLogSource[] }; scumLiveData?: LiveData }; const liveData = declaration.scumLiveData; if (!liveData) return []; const errors: string[] = []; const declaredCapabilities = new Set(declaration.capabilities ?? []); + const declaredPermissions = new Set(declaration.permissions ?? []); const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []); + const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []); + const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? []; + const logSources = new Set((declaration.runtimeProfiles?.logSources ?? []).map((source) => source.key ?? "")); + const assetFiles = new Set((declaration.assetFiles ?? []).map((asset) => asset.path ?? "")); const probe = liveData.probe; const location = "manifest.scumLiveData"; + const digestPattern = /^sha256:[a-fA-F0-9]{64}$/; + const fingerprintPattern = /^(sha256:)?[a-fA-F0-9]{16,128}$/; + const adapterPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/; + const logicalKeyPattern = /^[a-z0-9][a-z0-9._/-]{0,119}$/; + const readCapabilities = new Set(["players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read"]); + errors.push(...scanUnsafeValues(liveData, location)); if (liveData.schemaVersion !== "1") errors.push(`${location}.schemaVersion: must be 1`); if (probe?.capability !== "remote.run.db.sqlite.probe") errors.push(`${location}.probe.capability: must be remote.run.db.sqlite.probe`); if (!declaredCapabilities.has("remote.run.db.sqlite.probe") || !remoteCapabilities.has("remote.run.db.sqlite.probe")) errors.push(`${location}.probe: plugin and remoteAccess must declare remote.run.db.sqlite.probe`); - const transport = (declaration.runtimeProfiles?.transportProfiles ?? []).find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey); + const transport = transportProfiles.find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey); if (!transport) { errors.push(`${location}.probe.targetKey: must reference a declared runtime transport profile or target`); } else { @@ -1098,6 +1120,119 @@ export function validateSCUMLiveDataManifest(manifest: unknown): string[] { } if (gate.gate === "disabled" && gate.evidenceStatus === "compatible") errors.push(`${gateLocation}.evidenceStatus: disabled gates must not claim compatible evidence`); } + const requireAsset = (asset: Asset, assetLocation: string): void => { + const key = asset.key ?? ""; + if (!logicalKeyPattern.test(key)) errors.push(`${assetLocation}.key: must be a safe logical key`); + if (!adapterPattern.test(asset.adapterVersion ?? "")) errors.push(`${assetLocation}.adapterVersion: must be a bounded adapter version`); + if (!asset.assetPath || !isSafeRelativePathRef(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be a contained package-relative path`); + if (asset.assetPath && !assetFiles.has(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be declared in manifest.assetFiles`); + if (!digestPattern.test(asset.digest ?? "")) errors.push(`${assetLocation}.digest: must be a sha256 digest`); + }; + const requireUniqueAssetKeys = (assets: Asset[] | undefined, collection: string): void => { + const seenKeys = new Set(); + for (const [index, asset] of (assets ?? []).entries()) { + const key = asset.key ?? ""; + if (seenKeys.has(key)) errors.push(`${location}.${collection}[${index}].key: duplicate asset key ${key}`); + seenKeys.add(key); + } + }; + const transportByKey = (key?: string): Transport | undefined => transportProfiles.find((candidate) => candidate.key === key); + const requireRefs = (entry: Record, entryLocation: string, refs: string[]): void => { + for (const field of refs) { + const ref = entry[field]; + if (typeof ref !== "string" || !isSafeRelativeJsonRef(ref)) errors.push(`${entryLocation}.${field}: must be a safe relative JSON schema reference`); + } + }; + requireUniqueAssetKeys(liveData.logParsers, "logParsers"); + for (const [index, parser] of (liveData.logParsers ?? []).entries()) { + const itemLocation = `${location}.logParsers[${index}]`; + requireAsset(parser, itemLocation); + if (!adapterPattern.test(parser.parserVersion ?? "")) errors.push(`${itemLocation}.parserVersion: must be a bounded parser version`); + if (!logSources.has(parser.sourceKey ?? "")) errors.push(`${itemLocation}.sourceKey: must reference a declared runtime log source`); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(parser.eventType ?? "")) errors.push(`${itemLocation}.eventType: must be a safe event type`); + requireRefs(parser as Record, itemLocation, ["eventSchemaRef"]); + if (!Number.isInteger(parser.maxLineBytes) || (parser.maxLineBytes ?? 0) < 1 || (parser.maxLineBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxLineBytes: must be between 1 and 65536`); + if (parser.cursorPolicy !== "source-generation-sequence") errors.push(`${itemLocation}.cursorPolicy: must be source-generation-sequence`); + if (parser.privacy?.stripNetworkIdentifiers !== true || parser.privacy?.logicalEventIdentity !== "native-or-sanitized-fields") errors.push(`${itemLocation}.privacy: must strip network identifiers and use a privacy-safe logical identity`); + } + requireUniqueAssetKeys(liveData.sqliteQueries, "sqliteQueries"); + for (const [index, query] of (liveData.sqliteQueries ?? []).entries()) { + const itemLocation = `${location}.sqliteQueries[${index}]`; + 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`); + 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`); + if (!Number.isInteger(query.maxResultBytes) || (query.maxResultBytes ?? 0) < 1 || (query.maxResultBytes ?? 0) > 1048576) errors.push(`${itemLocation}.maxResultBytes: must be between 1 and 1048576`); + const queryTransport = transportByKey(query.transportKey); + if (!queryTransport || queryTransport.kind !== "sqlite" || !queryTransport.capabilities?.includes("remote.run.db.sqlite.query") || queryTransport.targetKey !== query.targetKey) errors.push(`${itemLocation}.transportKey: must reference sqlite transport with remote.run.db.sqlite.query and matching targetKey`); + if (!declaredCapabilities.has("remote.run.db.sqlite.query") || !remoteCapabilities.has("remote.run.db.sqlite.query") || !remoteDatabaseEngines.has("sqlite")) errors.push(`${itemLocation}: sqlite query assets require plugin and remote-access sqlite query capability`); + } + for (const [index, cadence] of (liveData.syncCadences ?? []).entries()) { + const itemLocation = `${location}.syncCadences[${index}]`; + if (!readCapabilities.has(cadence.capability ?? "")) errors.push(`${itemLocation}.capability: must be a read capability`); + if (!Number.isInteger(cadence.intervalSeconds) || (cadence.intervalSeconds ?? 0) < 5 || (cadence.intervalSeconds ?? 0) > 86400) errors.push(`${itemLocation}.intervalSeconds: must be between 5 and 86400`); + if (!Number.isInteger(cadence.jitterPercent) || (cadence.jitterPercent ?? -1) < 0 || (cadence.jitterPercent ?? 101) > 100) errors.push(`${itemLocation}.jitterPercent: must be between 0 and 100`); + if (!Number.isInteger(cadence.timeoutMs) || (cadence.timeoutMs ?? 0) < 1 || (cadence.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`); + if (!Number.isInteger(cadence.maxConcurrentPerServer) || (cadence.maxConcurrentPerServer ?? 0) < 1 || (cadence.maxConcurrentPerServer ?? 0) > 16) errors.push(`${itemLocation}.maxConcurrentPerServer: must be between 1 and 16`); + } + requireUniqueAssetKeys(liveData.typedRconTemplates, "typedRconTemplates"); + const typedRCONKeys = new Set((liveData.typedRconTemplates ?? []).map((template) => template.key ?? "")); + for (const [index, template] of (liveData.typedRconTemplates ?? []).entries()) { + const itemLocation = `${location}.typedRconTemplates[${index}]`; + 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`); + 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"]); + if (template.confirmationSchemaRef && !isSafeRelativeJsonRef(template.confirmationSchemaRef)) errors.push(`${itemLocation}.confirmationSchemaRef: must be a safe relative JSON schema reference`); + if (!Number.isInteger(template.timeoutMs) || (template.timeoutMs ?? 0) < 1 || (template.timeoutMs ?? 0) > 60000) errors.push(`${itemLocation}.timeoutMs: must be between 1 and 60000`); + if (!Number.isInteger(template.maxPayloadBytes) || (template.maxPayloadBytes ?? 0) < 1 || (template.maxPayloadBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxPayloadBytes: must be between 1 and 65536`); + const rconTransport = transportByKey(template.transportKey); + if (!rconTransport || rconTransport.kind !== "rcon" || !rconTransport.capabilities?.includes("remote.run.protected.rcon") || rconTransport.targetKey !== template.targetKey) errors.push(`${itemLocation}.transportKey: must reference rcon transport with remote.run.protected.rcon and matching targetKey`); + if (!declaredCapabilities.has("remote.run.protected.rcon") || !remoteCapabilities.has("remote.run.protected.rcon")) errors.push(`${itemLocation}: typed RCON templates require plugin and remote-access protected RCON capability`); + } + requireUniqueAssetKeys(liveData.guardedMutations, "guardedMutations"); + for (const [index, mutation] of (liveData.guardedMutations ?? []).entries()) { + const itemLocation = `${location}.guardedMutations[${index}]`; + 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`); + 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`); + if (!Number.isInteger(mutation.maxPayloadBytes) || (mutation.maxPayloadBytes ?? 0) < 1 || (mutation.maxPayloadBytes ?? 0) > 65536) errors.push(`${itemLocation}.maxPayloadBytes: must be between 1 and 65536`); + if (mutation.maxRowsAffected !== 1) errors.push(`${itemLocation}.maxRowsAffected: must be exactly 1`); + if (mutation.safety?.requiresExpectedChecksum !== true || mutation.safety?.requiresBackupEvidence !== true || mutation.safety?.requiresOfflineOrMaintenance !== true || mutation.safety?.requiresReadAfterWrite !== true) errors.push(`${itemLocation}.safety: must require checksum, backup evidence, offline/maintenance, and read-after-write`); + const mutationTransport = transportByKey(mutation.transportKey); + if (!mutationTransport || mutationTransport.kind !== "sqlite" || !mutationTransport.capabilities?.includes("remote.run.protected.sql") || mutationTransport.targetKey !== mutation.targetKey) errors.push(`${itemLocation}.transportKey: must reference sqlite transport with remote.run.protected.sql and matching targetKey`); + if (!declaredCapabilities.has("remote.run.protected.sql") || !remoteCapabilities.has("remote.run.protected.sql") || !remoteDatabaseEngines.has("sqlite")) errors.push(`${itemLocation}: guarded mutations require plugin and remote-access protected sqlite capability`); + } + requireUniqueAssetKeys(liveData.mapAssets, "mapAssets"); + for (const [index, mapAsset] of (liveData.mapAssets ?? []).entries()) { + const itemLocation = `${location}.mapAssets[${index}]`; + requireAsset(mapAsset, itemLocation); + if (!fingerprintPattern.test(mapAsset.requiredSchemaFingerprint ?? "")) errors.push(`${itemLocation}.requiredSchemaFingerprint: must be a schema fingerprint`); + 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`); + if (!digestPattern.test(mapAsset.transformDigest ?? "")) errors.push(`${itemLocation}.transformDigest: must be a sha256 digest`); + const bounds = mapAsset.worldBounds; + if (!bounds || !Number.isFinite(bounds.minX) || !Number.isFinite(bounds.minY) || !Number.isFinite(bounds.maxX) || !Number.isFinite(bounds.maxY) || (bounds.minX ?? 0) >= (bounds.maxX ?? 0) || (bounds.minY ?? 0) >= (bounds.maxY ?? 0)) errors.push(`${itemLocation}.worldBounds: must define finite increasing bounds`); + if (!Number.isInteger(mapAsset.image?.width) || (mapAsset.image?.width ?? 0) < 1 || !Number.isInteger(mapAsset.image?.height) || (mapAsset.image?.height ?? 0) < 1) errors.push(`${itemLocation}.image: width and height must be positive integers`); + } + requireUniqueAssetKeys(liveData.giftCatalogs, "giftCatalogs"); + for (const [index, catalog] of (liveData.giftCatalogs ?? []).entries()) { + const itemLocation = `${location}.giftCatalogs[${index}]`; + requireAsset(catalog, itemLocation); + if (!adapterPattern.test(catalog.catalogVersion ?? "")) errors.push(`${itemLocation}.catalogVersion: must be a bounded catalog version`); + 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 ?? []) { + if (!typedRCONKeys.has(templateKey)) errors.push(`${itemLocation}.transportTemplateKeys: undeclared typed RCON template ${templateKey}`); + } + } return errors; } diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index c967ad7..c38a721 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -312,10 +312,101 @@ export interface SCUMSchemaProbeDeclaration { bounds: SCUMSchemaProbeBoundsDeclaration; } +export type SCUMVersionedAssetDigest = `sha256:${string}`; +export type SCUMLiveDataReadCapability = Extract; +export type SCUMLiveDataWriteCapability = Extract; + +export interface SCUMVersionedAssetDeclaration { + key: string; + adapterVersion: string; + assetPath: string; + digest: SCUMVersionedAssetDigest; +} + +export interface SCUMLoginLogParserDeclaration extends SCUMVersionedAssetDeclaration { + parserVersion: string; + sourceKey: string; + eventType: string; + eventSchemaRef: string; + maxLineBytes: number; + cursorPolicy: "source-generation-sequence"; + privacy: { stripNetworkIdentifiers: true; logicalEventIdentity: "native-or-sanitized-fields" }; +} + +export interface SCUMSQLiteQueryAssetDeclaration extends SCUMVersionedAssetDeclaration { + capability: SCUMLiveDataReadCapability; + requiredSchemaFingerprint: string; + transportKey: string; + targetKey: string; + parameterSchemaRef: string; + resultSchemaRef: string; + maxRows: number; + timeoutMs: number; + maxResultBytes: number; +} + +export interface SCUMLiveDataSyncCadenceDeclaration { + capability: SCUMLiveDataReadCapability; + intervalSeconds: number; + jitterPercent: number; + timeoutMs: number; + maxConcurrentPerServer: number; +} + +export interface SCUMTypedRCONTemplateDeclaration extends SCUMVersionedAssetDeclaration { + capability: Extract; + requiredSchemaFingerprint?: string; + transportKey: string; + targetKey: string; + permission: Extract; + payloadSchemaRef: string; + resultSchemaRef: string; + confirmationSchemaRef?: string; + timeoutMs: number; + maxPayloadBytes: number; +} + +export interface SCUMGuardedMutationDeclaration extends SCUMVersionedAssetDeclaration { + capability: Extract; + requiredSchemaFingerprint: string; + transportKey: string; + targetKey: string; + permission: Extract; + payloadSchemaRef: string; + resultSchemaRef: string; + confirmationSchemaRef: string; + timeoutMs: number; + maxPayloadBytes: number; + maxRowsAffected: 1; + safety: { requiresExpectedChecksum: true; requiresBackupEvidence: true; requiresOfflineOrMaintenance: true; requiresReadAfterWrite: true }; +} + +export interface SCUMMapAssetDeclaration extends SCUMVersionedAssetDeclaration { + requiredSchemaFingerprint: string; + metadataSchemaRef: string; + transformAssetPath: string; + transformDigest: SCUMVersionedAssetDigest; + worldBounds: { minX: number; minY: number; maxX: number; maxY: number }; + image: { width: number; height: number }; +} + +export interface SCUMGiftCatalogDeclaration extends SCUMVersionedAssetDeclaration { + catalogVersion: string; + itemSchemaRef: string; + transportTemplateKeys: string[]; +} + export interface SCUMLiveDataManifestDeclaration { schemaVersion: "1"; probe: SCUMSchemaProbeDeclaration; capabilityGates: SCUMLiveDataCapabilityGateDeclaration[]; + logParsers?: SCUMLoginLogParserDeclaration[]; + sqliteQueries?: SCUMSQLiteQueryAssetDeclaration[]; + syncCadences?: SCUMLiveDataSyncCadenceDeclaration[]; + typedRconTemplates?: SCUMTypedRCONTemplateDeclaration[]; + guardedMutations?: SCUMGuardedMutationDeclaration[]; + mapAssets?: SCUMMapAssetDeclaration[]; + giftCatalogs?: SCUMGiftCatalogDeclaration[]; } export interface GameClientBridgeOperationSafety { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 5b4a22e..d55bb59 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -221,6 +221,46 @@ describe("plugin manifest validation", () => { expect(errors.some((error) => error.includes("enabled gates require immutable asset digests"))).toBe(true); }); + it("accepts safe SCUM live-data asset declarations without enabling unproven capabilities", () => { + const digest = (char: string) => `sha256:${char.repeat(64)}`; + const fingerprint = `sha256:${"f".repeat(64)}`; + const assetPaths = [ + "assets/scum-live/login-parser.json", + "assets/scum-live/queries/players-read.json", + "assets/scum-live/rcon/gift-grant.json", + "assets/scum-live/mutations/profile-xml-patch.json", + "assets/scum-live/map/island.png", + "assets/scum-live/map/transform.json", + "assets/scum-live/gifts/catalog.json" + ]; + const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => { + manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))]; + for (const assetPath of assetPaths) writeFixtureJSON(fixtureDir, assetPath, { packaged: true }); + manifest.scumLiveData = { + ...manifest.scumLiveData, + logParsers: [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[0], digest: digest("a"), 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("b"), 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("c"), 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("d"), 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("e"), requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest: digest("1"), 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("2"), catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }] + }; + }); + expect(errors).toEqual([]); + }); + + it("rejects unsafe SCUM live-data asset declarations", () => { + const errors = validateTemporaryScumCompanionManifest((manifest) => { + manifest.scumLiveData.sqliteQueries = [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: "/srv/scum/SCUM.db", digest: "sha256:bad", capability: "players.read", requiredSchemaFingerprint: "sha256:bad", 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: 1000, timeoutMs: 70000, maxResultBytes: 2097152, rawSql: "SELECT * FROM prisoner" }]; + manifest.scumLiveData.giftCatalogs = [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: "assets/scum-live/gifts/catalog.json", digest: `sha256:${"2".repeat(64)}`, catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["missing-template"] }]; + }); + expect(errors.some((error) => error.includes("assetPath") && error.includes("contained package-relative path"))).toBe(true); + expect(errors.some((error) => error.includes("digest") && error.includes("sha256"))).toBe(true); + expect(errors.some((error) => error.includes("must NOT have additional properties") || error.includes("additional properties"))).toBe(true); + expect(errors.some((error) => error.includes("transportTemplateKeys") && error.includes("missing-template"))).toBe(true); + }); + it("declares SCUM install/update and start lifecycle through plugin assets", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;