diff --git a/multiplayer-next.md b/multiplayer-next.md index 87485c84..174861c4 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -34,7 +34,7 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). measurable launch SLOs are defined in [MATCHMAKING-SLOs.md](docs/MATCHMAKING-SLOs.md). - [ ] Publish versioned OpenAPI/WebSocket contracts, stable IDs, legal state - transitions, revisions and idempotency semantics. + transitions, revisions and idempotency semantics ([v1 contracts](server/contracts/v1/)). - [ ] Add PostgreSQL queue ownership/active-participation fences, durable domain migrations/outbox and Redis indexes/TTLs; lost Redis writes must not split a proposal or corrupt durable state. diff --git a/server/contracts/v1/openapi.json b/server/contracts/v1/openapi.json new file mode 100644 index 00000000..e2f1be13 --- /dev/null +++ b/server/contracts/v1/openapi.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Cosmic Clash Matchmaking API", + "version": "1.0.0", + "description": "Versioned control-plane contract. Simulation traffic never uses this API." + }, + "servers": [{"url": "https://matchmaking.invalid/api/v1"}], + "security": [{"bearerAuth": []}], + "paths": { + "/session/steam": { + "post": { + "security": [], + "operationId": "createSteamSession", + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SteamLogin"}}}}, + "responses": {"200": {"description": "Session created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Session"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "429": {"$ref": "#/components/responses/RateLimited"}} + } + }, + "/profile": { + "get": {"operationId": "getProfile", "responses": {"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}} + }, + "/queue/tickets": { + "post": { + "operationId": "createQueueTicket", + "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueCreate"}}}}, + "responses": {"201": {"description": "Ticket created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueTicket"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/Invalid"}} + } + }, + "/queue/tickets/{ticketId}": { + "parameters": [{"$ref": "#/components/parameters/TicketId"}], + "get": {"operationId": "getQueueTicket", "responses": {"200": {"description": "Ticket", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueTicket"}}}}, "404": {"$ref": "#/components/responses/NotFound"}}}, + "delete": {"operationId": "cancelQueueTicket", "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"204": {"description": "Cancelled"}, "409": {"$ref": "#/components/responses/Conflict"}}} + }, + "/queue/tickets/{ticketId}/heartbeat": { + "post": {"operationId": "heartbeatQueueTicket", "parameters": [{"$ref": "#/components/parameters/TicketId"}, {"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"200": {"description": "Ticket renewed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueTicket"}}}}, "409": {"$ref": "#/components/responses/Conflict"}}} + }, + "/proposals/{proposalId}/accept": { + "post": {"operationId": "acceptProposal", "parameters": [{"$ref": "#/components/parameters/ProposalId"}, {"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"200": {"description": "Proposal updated", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Proposal"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "410": {"$ref": "#/components/responses/Expired"}}} + }, + "/proposals/{proposalId}/decline": { + "post": {"operationId": "declineProposal", "parameters": [{"$ref": "#/components/parameters/ProposalId"}, {"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"200": {"description": "Proposal declined", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Proposal"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "410": {"$ref": "#/components/responses/Expired"}}} + }, + "/assignments/{matchId}": { + "get": {"operationId": "getAssignment", "parameters": [{"$ref": "#/components/parameters/MatchId"}], "responses": {"200": {"description": "Assignment", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Assignment"}}}}, "404": {"$ref": "#/components/responses/NotFound"}}} + }, + "/servers/{serverId}/register": { + "post": {"security": [{"serverCredential": []}], "operationId": "registerServer", "parameters": [{"$ref": "#/components/parameters/ServerId"}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerRegistration"}}}}, "responses": {"204": {"description": "Registered"}, "409": {"$ref": "#/components/responses/Conflict"}}} + }, + "/servers/{serverId}/result": { + "post": {"security": [{"serverCredential": []}], "operationId": "submitMatchResult", "parameters": [{"$ref": "#/components/parameters/ServerId"}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MatchResult"}}}}, "responses": {"202": {"description": "Result accepted"}, "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/Invalid"}}} + } + }, + "components": { + "securitySchemes": { + "bearerAuth": {"type": "http", "scheme": "bearer"}, + "serverCredential": {"type": "http", "scheme": "bearer", "bearerFormat": "match-bound workload credential"} + }, + "parameters": { + "IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": true, "schema": {"type": "string", "minLength": 16, "maxLength": 128}}, + "ExpectedRevision": {"name": "If-Match-Revision", "in": "header", "required": true, "schema": {"type": "integer", "minimum": 0}}, + "TicketId": {"name": "ticketId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}}, + "ProposalId": {"name": "proposalId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}}, + "MatchId": {"name": "matchId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}}, + "ServerId": {"name": "serverId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}} + }, + "responses": { + "Unauthorized": {"description": "Authentication failed"}, + "RateLimited": {"description": "Rate limit exceeded"}, + "Conflict": {"description": "Revision or idempotency conflict"}, + "Invalid": {"description": "Invalid state or schema"}, + "NotFound": {"description": "Resource not found"}, + "Expired": {"description": "Resource expired"} + }, + "schemas": { + "OpaqueId": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, + "SteamLogin": {"type": "object", "required": ["web_api_ticket"], "additionalProperties": false, "properties": {"web_api_ticket": {"type": "string", "minLength": 1, "maxLength": 4096}}}, + "Session": {"type": "object", "required": ["player_id", "expires_at", "access_token"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "expires_at": {"type": "string", "format": "date-time"}, "access_token": {"type": "string"}}}, + "Profile": {"type": "object", "required": ["player_id", "rating", "rd", "provisional"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "rating": {"type": "number"}, "rd": {"type": "number"}, "provisional": {"type": "boolean"}}}, + "QueueCreate": {"type": "object", "required": ["playlist", "client_build", "protocol_version"], "additionalProperties": false, "properties": {"playlist": {"type": "string", "enum": ["casual", "ranked"]}, "client_build": {"type": "string", "minLength": 1, "maxLength": 128}, "protocol_version": {"type": "integer", "minimum": 1}}}, + "QueueTicket": {"type": "object", "required": ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"], "additionalProperties": false, "properties": {"ticket_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "playlist": {"type": "string", "enum": ["casual", "ranked"]}, "state": {"$ref": "#/components/schemas/QueueState"}, "revision": {"type": "integer", "minimum": 0}, "enqueued_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}}}, + "QueueState": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}, + "Proposal": {"type": "object", "required": ["proposal_id", "revision", "state", "expires_at", "participants"], "additionalProperties": false, "properties": {"proposal_id": {"$ref": "#/components/schemas/OpaqueId"}, "revision": {"type": "integer", "minimum": 0}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}, "expires_at": {"type": "string", "format": "date-time"}, "participants": {"type": "array", "minItems": 2, "items": {"$ref": "#/components/schemas/OpaqueId"}}}}, + "Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "join_authorisation"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "server_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}, "expires_at": {"type": "string", "format": "date-time"}, "protocol_version": {"type": "integer", "minimum": 1}, "transport": {"type": "string", "enum": ["steam_sdr", "enet"]}, "join_authorisation": {"type": "string"}}}, + "ServerRegistration": {"type": "object", "required": ["match_id", "protocol_version", "image_digest", "assignment_ready"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "protocol_version": {"type": "integer", "minimum": 1}, "image_digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, "assignment_ready": {"type": "boolean"}}}, + "MatchResult": {"type": "object", "required": ["match_id", "result_nonce", "score", "integrity_state"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "result_nonce": {"type": "string", "minLength": 16, "maxLength": 128}, "score": {"type": "object", "required": ["team_0", "team_1"], "additionalProperties": false, "properties": {"team_0": {"type": "integer", "minimum": 0}, "team_1": {"type": "integer", "minimum": 0}}}, "integrity_state": {"type": "string", "enum": ["CERTIFIED", "SUPPRESSED", "REVIEW"]}}} + } + } +} diff --git a/server/contracts/v1/test_contracts.py b/server/contracts/v1/test_contracts.py new file mode 100644 index 00000000..8aef43a1 --- /dev/null +++ b/server/contracts/v1/test_contracts.py @@ -0,0 +1,63 @@ +"""Dependency-free structural checks for the versioned control-plane contract.""" + +import json +from pathlib import Path +import unittest + + +ROOT = Path(__file__).parent + + +class ContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.openapi = json.loads((ROOT / "openapi.json").read_text()) + cls.events = json.loads((ROOT / "websocket-events.json").read_text()) + + def test_openapi_is_versioned_and_has_core_surfaces(self): + self.assertEqual(self.openapi["openapi"], "3.1.0") + operations = { + operation["operationId"] + for path in self.openapi["paths"].values() + for operation in path.values() + if isinstance(operation, dict) and "operationId" in operation + } + self.assertTrue({ + "createSteamSession", "getProfile", "createQueueTicket", + "heartbeatQueueTicket", "cancelQueueTicket", "acceptProposal", + "declineProposal", "getAssignment", "registerServer", + "submitMatchResult", + } <= operations) + + def test_mutations_require_idempotency_and_revision(self): + parameters = self.openapi["components"]["parameters"] + self.assertEqual(parameters["IdempotencyKey"]["name"], "Idempotency-Key") + self.assertTrue(parameters["IdempotencyKey"]["required"]) + self.assertEqual(parameters["ExpectedRevision"]["name"], "If-Match-Revision") + for path, methods in self.openapi["paths"].items(): + for method, operation in methods.items(): + if method not in {"post", "delete", "put", "patch"} or "operationId" not in operation: + continue + if operation["operationId"] == "createSteamSession": + continue + refs = {item.get("$ref") for item in operation.get("parameters", [])} + self.assertIn("#/components/parameters/IdempotencyKey", refs, path) + + def test_state_vocabulary_is_shared(self): + queue_states = self.openapi["components"]["schemas"]["QueueState"]["enum"] + websocket_states = self.events["$defs"]["stateChanged"]["allOf"][1]["properties"]["state"]["enum"] + self.assertEqual(queue_states, websocket_states) + self.assertIn("ASSIGNMENT_READY", queue_states) + self.assertIn("RESULT_PENDING", queue_states) + + def test_events_have_revisioned_envelopes_and_no_credentials(self): + envelope = self.events["$defs"]["envelope"] + self.assertEqual(envelope["required"], ["event", "revision", "resource_id", "occurred_at"]) + serialized = json.dumps(self.events).lower() + self.assertNotIn("access_token", serialized) + self.assertNotIn("web_api_ticket", serialized) + self.assertNotIn("relay_ticket", serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/contracts/v1/websocket-events.json b/server/contracts/v1/websocket-events.json new file mode 100644 index 00000000..22bdaa62 --- /dev/null +++ b/server/contracts/v1/websocket-events.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cosmic-clash.invalid/contracts/v1/websocket-events.json", + "title": "Cosmic Clash control-plane WebSocket event", + "oneOf": [ + {"$ref": "#/$defs/stateChanged"}, + {"$ref": "#/$defs/proposalChanged"}, + {"$ref": "#/$defs/assignmentChanged"}, + {"$ref": "#/$defs/error"} + ], + "$defs": { + "envelope": {"type": "object", "required": ["event", "revision", "resource_id", "occurred_at"], "properties": {"revision": {"type": "integer", "minimum": 0}, "resource_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "occurred_at": {"type": "string", "format": "date-time"}}}, + "stateChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "state_changed"}, "state": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}}, "required": ["event", "state"]}]}, + "proposalChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "proposal_changed"}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}}, "required": ["event", "state"]}]}, + "assignmentChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "assignment_changed"}, "match_id": {"type": "string"}, "server_id": {"type": "string"}}, "required": ["event", "match_id", "server_id"]}]}, + "error": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "error"}, "code": {"type": "string", "enum": ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]}}, "required": ["event", "code"]}]} + } +}