feat(scum): add live data asset manifest contracts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 } } }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -312,10 +312,101 @@ export interface SCUMSchemaProbeDeclaration {
|
||||
bounds: SCUMSchemaProbeBoundsDeclaration;
|
||||
}
|
||||
|
||||
export type SCUMVersionedAssetDigest = `sha256:${string}`;
|
||||
export type SCUMLiveDataReadCapability = Extract<SCUMLiveDataCapability, "players.read" | "player-details.read" | "squads.read" | "squad-members.read" | "vehicles.read" | "flags.read" | "positions.read">;
|
||||
export type SCUMLiveDataWriteCapability = Extract<SCUMLiveDataCapability, "profile-xml.write" | "economy-command.write" | "gift-command.write">;
|
||||
|
||||
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<SCUMLiveDataWriteCapability, "economy-command.write" | "gift-command.write">;
|
||||
requiredSchemaFingerprint?: string;
|
||||
transportKey: string;
|
||||
targetKey: string;
|
||||
permission: Extract<PluginPermission, "server.game-client.command">;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
confirmationSchemaRef?: string;
|
||||
timeoutMs: number;
|
||||
maxPayloadBytes: number;
|
||||
}
|
||||
|
||||
export interface SCUMGuardedMutationDeclaration extends SCUMVersionedAssetDeclaration {
|
||||
capability: Extract<SCUMLiveDataWriteCapability, "profile-xml.write">;
|
||||
requiredSchemaFingerprint: string;
|
||||
transportKey: string;
|
||||
targetKey: string;
|
||||
permission: Extract<PluginPermission, "server.game-client.maintenance">;
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user