diff --git a/fixtures/conformance/datagram-v1.tsv b/fixtures/conformance/datagram-v1.tsv index 699f0f0..41c7212 100644 --- a/fixtures/conformance/datagram-v1.tsv +++ b/fixtures/conformance/datagram-v1.tsv @@ -4,3 +4,5 @@ invalid-short 1 datagram hex=564401 invalid:truncated invalid-version 1 datagram hex=564402010000000000000000000000000000010000 invalid:unsupported_version invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch +valid-video-empty 1 datagram hex=5644010a0000000000000000000000000000010000 valid +invalid-media-channel 1 datagram hex=5644010d0000000000000000000000000000010000 invalid:unknown_channel diff --git a/fixtures/manifest.json b/fixtures/manifest.json index ee365c2..87f7362 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -6,5 +6,5 @@ "fixtures/conformance/events-v1.tsv", "fixtures/conformance/tunnel-v1.tsv" ], - "corpus_sha256": "c91a512dc67aa9912b31b21144be2adfeacf0dc80dd8515bd3b4a8f52977e761" + "corpus_sha256": "0874d39dd14c0107e602ea8909f8d53673f964c67dc9fd5fcadb8641e6ebf592" } diff --git a/frames/datagram-v1.md b/frames/datagram-v1.md index bc4de6c..e132898 100644 --- a/frames/datagram-v1.md +++ b/frames/datagram-v1.md @@ -1,7 +1,9 @@ # VerseVDI control datagram v1 -Phase 3A reserves a bounded control-datagram envelope. It does not forward video, -audio, provider traffic, VM traffic, or arbitrary binary clipboard data. +Phase 3C adds bounded encoded video/audio and sequenced-input channels to the same +transport envelope. The gateway forwards encoded bytes; it does not decode, encode, +transcode, render, or expose provider traffic. Arbitrary binary clipboard data remains +rejected. All multi-byte integers are unsigned big-endian. The fixed header is 21 bytes: @@ -22,6 +24,9 @@ Truncated, oversized, unknown-version, unknown-channel, invalid-fragment, and length-mismatch frames are rejected before allocation proportional to the claimed payload. Media/provider identifiers are not registered channels. -Registered Phase 3A channels are `control.ack.v1`, `control.cancel.v1`, and -`clipboard.text.v1`. Clipboard payloads are UTF-8 JSON text contracts and remain -subject to the 65,536-byte text limit and explicit authorization. +Registered channels are `control.ack.v1`, `control.cancel.v1`, `clipboard.text.v1`, +`media.video.v1`, `media.audio.v1`, and `input.sequenced.v1`. Media/input frames use +application flow IDs 10, 11, and 12 and a path-MTU-safe payload limit of 1,179 bytes; +larger encoded units use at most 16 validated fragments. Clipboard payloads are UTF-8 +JSON text contracts and remain subject to the 65,536-byte text limit and explicit +authorization. diff --git a/frames/registry.json b/frames/registry.json index f346426..86b2d96 100644 --- a/frames/registry.json +++ b/frames/registry.json @@ -6,7 +6,10 @@ "channels": [ {"id": 1, "name": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024}, {"id": 2, "name": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048}, - {"id": 3, "name": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65515} + {"id": 3, "name": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65515}, + {"id": 10, "name": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1179}, + {"id": 11, "name": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1179}, + {"id": 12, "name": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1179} ], - "reserved_rejected": ["video", "audio", "provider", "vm", "file-transfer", "clipboard.binary"] + "reserved_rejected": ["provider", "vm", "file-transfer", "clipboard.binary"] } diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 0eb098b..bf7ad87 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -11,7 +11,7 @@ import ( "time" ) -const SchemaSHA256 = "36e4c8bac2eae674c1eba551c6ca8c64bf80fcc092ca63ec89a2c71c2bec86e1" +const SchemaSHA256 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "1" const NMinus1WireVersion = "0" @@ -65,6 +65,26 @@ type BrokerSession struct { Version int64 `json:"version"` } +type CapabilityProfile struct { + Transport string `json:"transport"` + Framing string `json:"framing"` + Media string `json:"media"` + Audio string `json:"audio"` + SourceRateControl string `json:"source_rate_control"` + ClientDecode string `json:"client_decode"` +} + +type ChannelFrame struct { + Version string `json:"version"` + FlowID string `json:"flow_id"` + Sequence int64 `json:"sequence"` + Flags int64 `json:"flags"` + FragmentIndex int64 `json:"fragment_index"` + FragmentCount int64 `json:"fragment_count"` + TimestampMs int64 `json:"timestamp_ms"` + Payload string `json:"payload"` +} + type ClipboardText struct { Text string `json:"text"` Encoding string `json:"encoding"` @@ -137,6 +157,40 @@ type EventResume struct { LastSequence int64 `json:"last_sequence"` } +type GatewayDrain struct { + Version string `json:"version"` + GatewayID string `json:"gateway_id"` + Sequence int64 `json:"sequence"` + Reason string `json:"reason"` + Deadline string `json:"deadline"` +} + +type GatewayHeartbeat struct { + Version string `json:"version"` + GatewayID string `json:"gateway_id"` + Sequence int64 `json:"sequence"` + ObservedAt string `json:"observed_at"` + ActiveConnections int64 `json:"active_connections"` + EgressKbps int64 `json:"egress_kbps"` + State string `json:"state"` +} + +type GatewayRegistration struct { + Version string `json:"version"` + GatewayID string `json:"gateway_id"` + InstanceIdentity string `json:"instance_identity"` + CertificateIdentity string `json:"certificate_identity"` + PublicIdentity string `json:"public_identity"` + Address string `json:"address"` + ProviderIdentity string `json:"provider_identity"` + ProtocolMinVersion int64 `json:"protocol_min_version"` + ProtocolMaxVersion int64 `json:"protocol_max_version"` + ConnectionCapacity int64 `json:"connection_capacity"` + BandwidthCapacityKbps int64 `json:"bandwidth_capacity_kbps"` + Features []string `json:"features"` + Capabilities CapabilityProfile `json:"capabilities"` +} + type GrantReference struct { OpaqueValue string `json:"opaque_value"` ExpiresAt string `json:"expires_at"` @@ -185,6 +239,14 @@ type PageInfo struct { NextCursor string `json:"next_cursor"` } +type ProviderState struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + State string `json:"state"` + CleanupPending bool `json:"cleanup_pending"` + Channels []string `json:"channels"` +} + type ReauthGrant struct { Token string `json:"token"` Purpose string `json:"purpose"` @@ -229,6 +291,18 @@ type ResourceList struct { Page PageInfo `json:"page"` } +type SessionAuthority struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + Audience string `json:"audience"` + ReconnectSequence int64 `json:"reconnect_sequence"` + ExpiresAt string `json:"expires_at"` + Capabilities CapabilityProfile `json:"capabilities"` + ProviderProfile string `json:"provider_profile"` + ProviderIdentity string `json:"provider_identity"` +} + type SessionRequest struct { ClientDeviceID string `json:"client_device_id"` DeviceKeyID string `json:"device_key_id"` @@ -237,6 +311,24 @@ type SessionRequest struct { PolicySnapshot AllocationPolicy `json:"policy_snapshot"` } +type StableError struct { + Version string `json:"version"` + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +type TunnelAdmissionRequest struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + Audience string `json:"audience"` + Grant string `json:"grant"` + ReconnectSequence int64 `json:"reconnect_sequence"` + ClientNonce string `json:"client_nonce"` + Capabilities CapabilityProfile `json:"capabilities"` +} + type VersionNegotiation struct { SupportedVersions []string `json:"supported_versions"` Features []string `json:"features"` @@ -662,6 +754,237 @@ func EncodeBrokerSession(value BrokerSession) ([]byte, error) { return json.Marshal(value) } +func (v CapabilityProfile) Validate() error { + var violations []FieldViolation + if v.Transport == "" { + violations = append(violations, FieldViolation{Field: "transport", Code: "required"}) + } + if len(v.Transport) < 1 && v.Transport != "" { + violations = append(violations, FieldViolation{Field: "transport", Code: "min_length"}) + } + if len(v.Transport) > 64 { + violations = append(violations, FieldViolation{Field: "transport", Code: "max_length"}) + } + if v.Framing == "" { + violations = append(violations, FieldViolation{Field: "framing", Code: "required"}) + } + if len(v.Framing) < 1 && v.Framing != "" { + violations = append(violations, FieldViolation{Field: "framing", Code: "min_length"}) + } + if len(v.Framing) > 64 { + violations = append(violations, FieldViolation{Field: "framing", Code: "max_length"}) + } + if v.Media == "" { + violations = append(violations, FieldViolation{Field: "media", Code: "required"}) + } + if len(v.Media) < 1 && v.Media != "" { + violations = append(violations, FieldViolation{Field: "media", Code: "min_length"}) + } + if len(v.Media) > 64 { + violations = append(violations, FieldViolation{Field: "media", Code: "max_length"}) + } + if v.Audio == "" { + violations = append(violations, FieldViolation{Field: "audio", Code: "required"}) + } + if len(v.Audio) < 1 && v.Audio != "" { + violations = append(violations, FieldViolation{Field: "audio", Code: "min_length"}) + } + if len(v.Audio) > 64 { + violations = append(violations, FieldViolation{Field: "audio", Code: "max_length"}) + } + if v.SourceRateControl == "" { + violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "required"}) + } + if len(v.SourceRateControl) < 1 && v.SourceRateControl != "" { + violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "min_length"}) + } + if len(v.SourceRateControl) > 64 { + violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "max_length"}) + } + if v.ClientDecode == "" { + violations = append(violations, FieldViolation{Field: "client_decode", Code: "required"}) + } + if len(v.ClientDecode) < 1 && v.ClientDecode != "" { + violations = append(violations, FieldViolation{Field: "client_decode", Code: "min_length"}) + } + if len(v.ClientDecode) > 64 { + violations = append(violations, FieldViolation{Field: "client_decode", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeCapabilityProfile(data []byte) (CapabilityProfile, error) { + var value CapabilityProfile + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["audio"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "audio", Code: "required"}}} + } + if raw, ok := fields["client_decode"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "client_decode", Code: "required"}}} + } + if raw, ok := fields["framing"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "framing", Code: "required"}}} + } + if raw, ok := fields["media"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "media", Code: "required"}}} + } + if raw, ok := fields["source_rate_control"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "source_rate_control", Code: "required"}}} + } + if raw, ok := fields["transport"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "transport", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeCapabilityProfile(value CapabilityProfile) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v ChannelFrame) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.FlowID == "" { + violations = append(violations, FieldViolation{Field: "flow_id", Code: "required"}) + } + if len(v.FlowID) < 1 && v.FlowID != "" { + violations = append(violations, FieldViolation{Field: "flow_id", Code: "min_length"}) + } + if len(v.FlowID) > 64 { + violations = append(violations, FieldViolation{Field: "flow_id", Code: "max_length"}) + } + if v.Sequence != 0 && v.Sequence < 0 { + violations = append(violations, FieldViolation{Field: "sequence", Code: "minimum"}) + } + if v.Flags != 0 && v.Flags < 0 { + violations = append(violations, FieldViolation{Field: "flags", Code: "minimum"}) + } + if v.Flags > 255 { + violations = append(violations, FieldViolation{Field: "flags", Code: "maximum"}) + } + if v.FragmentIndex != 0 && v.FragmentIndex < 0 { + violations = append(violations, FieldViolation{Field: "fragment_index", Code: "minimum"}) + } + if v.FragmentIndex > 15 { + violations = append(violations, FieldViolation{Field: "fragment_index", Code: "maximum"}) + } + if v.FragmentCount == 0 { + violations = append(violations, FieldViolation{Field: "fragment_count", Code: "required"}) + } + if v.FragmentCount != 0 && v.FragmentCount < 1 { + violations = append(violations, FieldViolation{Field: "fragment_count", Code: "minimum"}) + } + if v.FragmentCount > 16 { + violations = append(violations, FieldViolation{Field: "fragment_count", Code: "maximum"}) + } + if v.TimestampMs != 0 && v.TimestampMs < 0 { + violations = append(violations, FieldViolation{Field: "timestamp_ms", Code: "minimum"}) + } + if v.Payload == "" { + violations = append(violations, FieldViolation{Field: "payload", Code: "required"}) + } + if len(v.Payload) > 87384 { + violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeChannelFrame(data []byte) (ChannelFrame, error) { + var value ChannelFrame + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["flags"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "flags", Code: "required"}}} + } + if raw, ok := fields["flow_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "flow_id", Code: "required"}}} + } + if raw, ok := fields["fragment_count"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "fragment_count", Code: "required"}}} + } + if raw, ok := fields["fragment_index"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "fragment_index", Code: "required"}}} + } + if raw, ok := fields["payload"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "payload", Code: "required"}}} + } + if raw, ok := fields["sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "sequence", Code: "required"}}} + } + if raw, ok := fields["timestamp_ms"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "timestamp_ms", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + if raw, ok := fields["payload"]; ok && len(raw) > 65536 { + return value, ValidationError{Violations: []FieldViolation{{Field: "payload", Code: "max_bytes"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeChannelFrame(value ChannelFrame) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v ClipboardText) Validate() error { var violations []FieldViolation if v.Text == "" { @@ -1583,6 +1906,406 @@ func EncodeFieldViolation(value FieldViolation) ([]byte, error) { return json.Marshal(value) } +func (v GatewayDrain) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.Sequence == 0 { + violations = append(violations, FieldViolation{Field: "sequence", Code: "required"}) + } + if v.Sequence != 0 && v.Sequence < 1 { + violations = append(violations, FieldViolation{Field: "sequence", Code: "minimum"}) + } + if v.Reason == "" { + violations = append(violations, FieldViolation{Field: "reason", Code: "required"}) + } + if len(v.Reason) < 1 && v.Reason != "" { + violations = append(violations, FieldViolation{Field: "reason", Code: "min_length"}) + } + if len(v.Reason) > 256 { + violations = append(violations, FieldViolation{Field: "reason", Code: "max_length"}) + } + if v.Deadline == "" { + violations = append(violations, FieldViolation{Field: "deadline", Code: "required"}) + } + if len(v.Deadline) > 64 { + violations = append(violations, FieldViolation{Field: "deadline", Code: "max_length"}) + } + if v.Deadline != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.Deadline); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.Deadline { + violations = append(violations, FieldViolation{Field: "deadline", Code: "invalid_time"}) + } + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayDrain(data []byte) (GatewayDrain, error) { + var value GatewayDrain + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["deadline"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "deadline", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["reason"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reason", Code: "required"}}} + } + if raw, ok := fields["sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "sequence", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayDrain(value GatewayDrain) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v GatewayHeartbeat) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.Sequence == 0 { + violations = append(violations, FieldViolation{Field: "sequence", Code: "required"}) + } + if v.Sequence != 0 && v.Sequence < 1 { + violations = append(violations, FieldViolation{Field: "sequence", Code: "minimum"}) + } + if v.ObservedAt == "" { + violations = append(violations, FieldViolation{Field: "observed_at", Code: "required"}) + } + if len(v.ObservedAt) > 64 { + violations = append(violations, FieldViolation{Field: "observed_at", Code: "max_length"}) + } + if v.ObservedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.ObservedAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ObservedAt { + violations = append(violations, FieldViolation{Field: "observed_at", Code: "invalid_time"}) + } + } + if v.ActiveConnections != 0 && v.ActiveConnections < 0 { + violations = append(violations, FieldViolation{Field: "active_connections", Code: "minimum"}) + } + if v.ActiveConnections > 1000000 { + violations = append(violations, FieldViolation{Field: "active_connections", Code: "maximum"}) + } + if v.EgressKbps != 0 && v.EgressKbps < 0 { + violations = append(violations, FieldViolation{Field: "egress_kbps", Code: "minimum"}) + } + if v.EgressKbps > 1000000000 { + violations = append(violations, FieldViolation{Field: "egress_kbps", Code: "maximum"}) + } + if v.State == "" { + violations = append(violations, FieldViolation{Field: "state", Code: "required"}) + } + if v.State != "" && !(v.State == "ready" || v.State == "draining" || v.State == "offline") { + violations = append(violations, FieldViolation{Field: "state", Code: "invalid_value"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayHeartbeat(data []byte) (GatewayHeartbeat, error) { + var value GatewayHeartbeat + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["active_connections"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "active_connections", Code: "required"}}} + } + if raw, ok := fields["egress_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "egress_kbps", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["observed_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "observed_at", Code: "required"}}} + } + if raw, ok := fields["sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "sequence", Code: "required"}}} + } + if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayHeartbeat(value GatewayHeartbeat) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v GatewayRegistration) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.InstanceIdentity == "" { + violations = append(violations, FieldViolation{Field: "instance_identity", Code: "required"}) + } + if len(v.InstanceIdentity) < 1 && v.InstanceIdentity != "" { + violations = append(violations, FieldViolation{Field: "instance_identity", Code: "min_length"}) + } + if len(v.InstanceIdentity) > 512 { + violations = append(violations, FieldViolation{Field: "instance_identity", Code: "max_length"}) + } + if v.CertificateIdentity == "" { + violations = append(violations, FieldViolation{Field: "certificate_identity", Code: "required"}) + } + if len(v.CertificateIdentity) < 1 && v.CertificateIdentity != "" { + violations = append(violations, FieldViolation{Field: "certificate_identity", Code: "min_length"}) + } + if len(v.CertificateIdentity) > 512 { + violations = append(violations, FieldViolation{Field: "certificate_identity", Code: "max_length"}) + } + if v.PublicIdentity == "" { + violations = append(violations, FieldViolation{Field: "public_identity", Code: "required"}) + } + if len(v.PublicIdentity) < 1 && v.PublicIdentity != "" { + violations = append(violations, FieldViolation{Field: "public_identity", Code: "min_length"}) + } + if len(v.PublicIdentity) > 256 { + violations = append(violations, FieldViolation{Field: "public_identity", Code: "max_length"}) + } + if v.Address == "" { + violations = append(violations, FieldViolation{Field: "address", Code: "required"}) + } + if len(v.Address) < 1 && v.Address != "" { + violations = append(violations, FieldViolation{Field: "address", Code: "min_length"}) + } + if len(v.Address) > 256 { + violations = append(violations, FieldViolation{Field: "address", Code: "max_length"}) + } + if v.ProviderIdentity == "" { + violations = append(violations, FieldViolation{Field: "provider_identity", Code: "required"}) + } + if len(v.ProviderIdentity) < 1 && v.ProviderIdentity != "" { + violations = append(violations, FieldViolation{Field: "provider_identity", Code: "min_length"}) + } + if len(v.ProviderIdentity) > 256 { + violations = append(violations, FieldViolation{Field: "provider_identity", Code: "max_length"}) + } + if v.ProtocolMinVersion == 0 { + violations = append(violations, FieldViolation{Field: "protocol_min_version", Code: "required"}) + } + if v.ProtocolMinVersion != 0 && v.ProtocolMinVersion < 1 { + violations = append(violations, FieldViolation{Field: "protocol_min_version", Code: "minimum"}) + } + if v.ProtocolMinVersion > 100 { + violations = append(violations, FieldViolation{Field: "protocol_min_version", Code: "maximum"}) + } + if v.ProtocolMaxVersion == 0 { + violations = append(violations, FieldViolation{Field: "protocol_max_version", Code: "required"}) + } + if v.ProtocolMaxVersion != 0 && v.ProtocolMaxVersion < 1 { + violations = append(violations, FieldViolation{Field: "protocol_max_version", Code: "minimum"}) + } + if v.ProtocolMaxVersion > 100 { + violations = append(violations, FieldViolation{Field: "protocol_max_version", Code: "maximum"}) + } + if v.ConnectionCapacity == 0 { + violations = append(violations, FieldViolation{Field: "connection_capacity", Code: "required"}) + } + if v.ConnectionCapacity != 0 && v.ConnectionCapacity < 1 { + violations = append(violations, FieldViolation{Field: "connection_capacity", Code: "minimum"}) + } + if v.ConnectionCapacity > 1000000 { + violations = append(violations, FieldViolation{Field: "connection_capacity", Code: "maximum"}) + } + if v.BandwidthCapacityKbps == 0 { + violations = append(violations, FieldViolation{Field: "bandwidth_capacity_kbps", Code: "required"}) + } + if v.BandwidthCapacityKbps != 0 && v.BandwidthCapacityKbps < 1 { + violations = append(violations, FieldViolation{Field: "bandwidth_capacity_kbps", Code: "minimum"}) + } + if v.BandwidthCapacityKbps > 1000000000 { + violations = append(violations, FieldViolation{Field: "bandwidth_capacity_kbps", Code: "maximum"}) + } + if v.Features == nil { + violations = append(violations, FieldViolation{Field: "features", Code: "required"}) + } + if len(v.Features) > 64 { + violations = append(violations, FieldViolation{Field: "features", Code: "max_items"}) + } + if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) { + violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"}) + } + if err := v.Capabilities.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayRegistration(data []byte) (GatewayRegistration, error) { + var value GatewayRegistration + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["address"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "address", Code: "required"}}} + } + if raw, ok := fields["bandwidth_capacity_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bandwidth_capacity_kbps", Code: "required"}}} + } + if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}} + } + if raw, ok := fields["certificate_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "certificate_identity", Code: "required"}}} + } + if raw, ok := fields["connection_capacity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "connection_capacity", Code: "required"}}} + } + if raw, ok := fields["features"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "features", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["instance_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "instance_identity", Code: "required"}}} + } + if raw, ok := fields["protocol_max_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "protocol_max_version", Code: "required"}}} + } + if raw, ok := fields["protocol_min_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "protocol_min_version", Code: "required"}}} + } + if raw, ok := fields["provider_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "provider_identity", Code: "required"}}} + } + if raw, ok := fields["public_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "public_identity", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayRegistration(value GatewayRegistration) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v GrantReference) Validate() error { var violations []FieldViolation if v.OpaqueValue == "" { @@ -2180,6 +2903,90 @@ func EncodePageInfo(value PageInfo) ([]byte, error) { return json.Marshal(value) } +func (v ProviderState) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.State == "" { + violations = append(violations, FieldViolation{Field: "state", Code: "required"}) + } + if v.State != "" && !(v.State == "starting" || v.State == "ready" || v.State == "disconnected" || v.State == "terminating" || v.State == "terminated" || v.State == "cleanup_pending" || v.State == "failed") { + violations = append(violations, FieldViolation{Field: "state", Code: "invalid_value"}) + } + if v.Channels == nil { + violations = append(violations, FieldViolation{Field: "channels", Code: "required"}) + } + if len(v.Channels) > 8 { + violations = append(violations, FieldViolation{Field: "channels", Code: "max_items"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeProviderState(data []byte) (ProviderState, error) { + var value ProviderState + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["channels"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "channels", Code: "required"}}} + } + if raw, ok := fields["cleanup_pending"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "cleanup_pending", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeProviderState(value ProviderState) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v ReauthGrant) Validate() error { var violations []FieldViolation if v.Token == "" { @@ -2734,6 +3541,143 @@ func EncodeResourceList(value ResourceList) ([]byte, error) { return json.Marshal(value) } +func (v SessionAuthority) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.Audience == "" { + violations = append(violations, FieldViolation{Field: "audience", Code: "required"}) + } + if len(v.Audience) < 1 && v.Audience != "" { + violations = append(violations, FieldViolation{Field: "audience", Code: "min_length"}) + } + if len(v.Audience) > 256 { + violations = append(violations, FieldViolation{Field: "audience", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.ExpiresAt == "" { + violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"}) + } + if len(v.ExpiresAt) > 64 { + violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"}) + } + if v.ExpiresAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt { + violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"}) + } + } + if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) { + violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"}) + } + if err := v.Capabilities.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"}) + } + if v.ProviderProfile == "" { + violations = append(violations, FieldViolation{Field: "provider_profile", Code: "required"}) + } + if v.ProviderProfile != "" && !(v.ProviderProfile == "apollo") { + violations = append(violations, FieldViolation{Field: "provider_profile", Code: "invalid_value"}) + } + if v.ProviderIdentity == "" { + violations = append(violations, FieldViolation{Field: "provider_identity", Code: "required"}) + } + if len(v.ProviderIdentity) < 1 && v.ProviderIdentity != "" { + violations = append(violations, FieldViolation{Field: "provider_identity", Code: "min_length"}) + } + if len(v.ProviderIdentity) > 256 { + violations = append(violations, FieldViolation{Field: "provider_identity", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeSessionAuthority(data []byte) (SessionAuthority, error) { + var value SessionAuthority + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["audience"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "audience", Code: "required"}}} + } + if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}} + } + if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["provider_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "provider_identity", Code: "required"}}} + } + if raw, ok := fields["provider_profile"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "provider_profile", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeSessionAuthority(value SessionAuthority) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v SessionRequest) Validate() error { var violations []FieldViolation if v.ClientDeviceID == "" { @@ -2833,6 +3777,210 @@ func EncodeSessionRequest(value SessionRequest) ([]byte, error) { return json.Marshal(value) } +func (v StableError) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.Code == "" { + violations = append(violations, FieldViolation{Field: "code", Code: "required"}) + } + if len(v.Code) < 1 && v.Code != "" { + violations = append(violations, FieldViolation{Field: "code", Code: "min_length"}) + } + if len(v.Code) > 128 { + violations = append(violations, FieldViolation{Field: "code", Code: "max_length"}) + } + if v.Message == "" { + violations = append(violations, FieldViolation{Field: "message", Code: "required"}) + } + if len(v.Message) < 1 && v.Message != "" { + violations = append(violations, FieldViolation{Field: "message", Code: "min_length"}) + } + if len(v.Message) > 512 { + violations = append(violations, FieldViolation{Field: "message", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeStableError(data []byte) (StableError, error) { + var value StableError + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["code"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "code", Code: "required"}}} + } + if raw, ok := fields["message"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "message", Code: "required"}}} + } + if raw, ok := fields["retryable"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "retryable", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeStableError(value StableError) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v TunnelAdmissionRequest) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.Audience == "" { + violations = append(violations, FieldViolation{Field: "audience", Code: "required"}) + } + if len(v.Audience) < 1 && v.Audience != "" { + violations = append(violations, FieldViolation{Field: "audience", Code: "min_length"}) + } + if len(v.Audience) > 256 { + violations = append(violations, FieldViolation{Field: "audience", Code: "max_length"}) + } + if v.Grant == "" { + violations = append(violations, FieldViolation{Field: "grant", Code: "required"}) + } + if len(v.Grant) < 43 && v.Grant != "" { + violations = append(violations, FieldViolation{Field: "grant", Code: "min_length"}) + } + if len(v.Grant) > 256 { + violations = append(violations, FieldViolation{Field: "grant", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.ClientNonce == "" { + violations = append(violations, FieldViolation{Field: "client_nonce", Code: "required"}) + } + if len(v.ClientNonce) < 16 && v.ClientNonce != "" { + violations = append(violations, FieldViolation{Field: "client_nonce", Code: "min_length"}) + } + if len(v.ClientNonce) > 128 { + violations = append(violations, FieldViolation{Field: "client_nonce", Code: "max_length"}) + } + if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) { + violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"}) + } + if err := v.Capabilities.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeTunnelAdmissionRequest(data []byte) (TunnelAdmissionRequest, error) { + var value TunnelAdmissionRequest + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["audience"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "audience", Code: "required"}}} + } + if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}} + } + if raw, ok := fields["client_nonce"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "client_nonce", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["grant"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "grant", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeTunnelAdmissionRequest(value TunnelAdmissionRequest) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v VersionNegotiation) Validate() error { var violations []FieldViolation if v.SupportedVersions == nil { diff --git a/gen/manifest.json b/gen/manifest.json index 6cda779..426e619 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -14,5 +14,5 @@ }, "generator_sha256": "cb975bcd42bf77641b6a0f44d5ec7a6fdba1858d6b8a865e0f04e53bad82648c", "protocol_version": "1.0.0", - "schema_sha256": "36e4c8bac2eae674c1eba551c6ca8c64bf80fcc092ca63ec89a2c71c2bec86e1" + "schema_sha256": "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec" } diff --git a/gen/protobuf/tunnel-v1.pb b/gen/protobuf/tunnel-v1.pb index 23d1177..9472b0d 100644 Binary files a/gen/protobuf/tunnel-v1.pb and b/gen/protobuf/tunnel-v1.pb differ diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 9baf3c2..828ab36 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1,6 +1,6 @@ // Code generated by tools/generate.py; DO NOT EDIT. #![allow(non_snake_case)] -pub const SCHEMA_SHA256: &str = "36e4c8bac2eae674c1eba551c6ca8c64bf80fcc092ca63ec89a2c71c2bec86e1"; +pub const SCHEMA_SHA256: &str = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"; pub const CURRENT_WIRE_VERSION: &str = "1"; pub const N_MINUS_1_WIRE_VERSION: &str = "0"; pub const N_MINUS_2_WIRE_VERSION: &str = "-1"; @@ -46,6 +46,28 @@ pub struct BrokerSession { pub version: i64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityProfile { + pub transport: String, + pub framing: String, + pub media: String, + pub audio: String, + pub sourceRateControl: String, + pub clientDecode: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelFrame { + pub version: String, + pub flowId: String, + pub sequence: i64, + pub flags: i64, + pub fragmentIndex: i64, + pub fragmentCount: i64, + pub timestampMs: i64, + pub payload: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClipboardText { pub text: String, @@ -133,6 +155,43 @@ pub struct FieldViolation { pub code: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayDrain { + pub version: String, + pub gatewayId: String, + pub sequence: i64, + pub reason: String, + pub deadline: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayHeartbeat { + pub version: String, + pub gatewayId: String, + pub sequence: i64, + pub observedAt: String, + pub activeConnections: i64, + pub egressKbps: i64, + pub state: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayRegistration { + pub version: String, + pub gatewayId: String, + pub instanceIdentity: String, + pub certificateIdentity: String, + pub publicIdentity: String, + pub address: String, + pub providerIdentity: String, + pub protocolMinVersion: i64, + pub protocolMaxVersion: i64, + pub connectionCapacity: i64, + pub bandwidthCapacityKbps: i64, + pub features: Vec, + pub capabilities: CapabilityProfile, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GrantReference { pub opaqueValue: String, @@ -189,6 +248,15 @@ pub struct PageInfo { pub nextCursor: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderState { + pub version: String, + pub sessionId: String, + pub state: String, + pub cleanupPending: bool, + pub channels: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReauthGrant { pub token: String, @@ -240,6 +308,19 @@ pub struct ResourceList { pub page: PageInfo, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionAuthority { + pub version: String, + pub sessionId: String, + pub gatewayId: String, + pub audience: String, + pub reconnectSequence: i64, + pub expiresAt: String, + pub capabilities: CapabilityProfile, + pub providerProfile: String, + pub providerIdentity: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionRequest { pub clientDeviceId: String, @@ -249,6 +330,26 @@ pub struct SessionRequest { pub policySnapshot: AllocationPolicy, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StableError { + pub version: String, + pub code: String, + pub message: String, + pub retryable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TunnelAdmissionRequest { + pub version: String, + pub sessionId: String, + pub gatewayId: String, + pub audience: String, + pub grant: String, + pub reconnectSequence: i64, + pub clientNonce: String, + pub capabilities: CapabilityProfile, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct VersionNegotiation { pub supportedVersions: Vec, diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index 843d3e8..6e1af76 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -1,7 +1,7 @@ // Code generated by tools/generate.py; DO NOT EDIT. import Foundation public typealias JSONObject = [String: String] -public let schemaSHA256 = "36e4c8bac2eae674c1eba551c6ca8c64bf80fcc092ca63ec89a2c71c2bec86e1" +public let schemaSHA256 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec" public let currentWireVersion = "1" public let nMinus1WireVersion = "0" public let nMinus2WireVersion = "-1" @@ -117,6 +117,66 @@ public struct BrokerSession: Codable, Equatable { } } +public struct CapabilityProfile: Codable, Equatable { + public let transport: String + public let framing: String + public let media: String + public let audio: String + public let sourceRateControl: String + public let clientDecode: String + enum CodingKeys: String, CodingKey { + case transport = "transport" + case framing = "framing" + case media = "media" + case audio = "audio" + case sourceRateControl = "source_rate_control" + case clientDecode = "client_decode" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + transport = try c.decode(String.self, forKey: .transport) + framing = try c.decode(String.self, forKey: .framing) + media = try c.decode(String.self, forKey: .media) + audio = try c.decode(String.self, forKey: .audio) + sourceRateControl = try c.decode(String.self, forKey: .sourceRateControl) + clientDecode = try c.decode(String.self, forKey: .clientDecode) + } +} + +public struct ChannelFrame: Codable, Equatable { + public let version: String + public let flowId: String + public let sequence: Int64 + public let flags: Int64 + public let fragmentIndex: Int64 + public let fragmentCount: Int64 + public let timestampMs: Int64 + public let payload: String + enum CodingKeys: String, CodingKey { + case version = "version" + case flowId = "flow_id" + case sequence = "sequence" + case flags = "flags" + case fragmentIndex = "fragment_index" + case fragmentCount = "fragment_count" + case timestampMs = "timestamp_ms" + case payload = "payload" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + flowId = try c.decode(String.self, forKey: .flowId) + sequence = try c.decode(Int64.self, forKey: .sequence) + flags = try c.decode(Int64.self, forKey: .flags) + fragmentIndex = try c.decode(Int64.self, forKey: .fragmentIndex) + fragmentCount = try c.decode(Int64.self, forKey: .fragmentCount) + timestampMs = try c.decode(Int64.self, forKey: .timestampMs) + payload = try c.decode(String.self, forKey: .payload) + } +} + public struct ClipboardText: Codable, Equatable { public let text: String public let encoding: String @@ -348,6 +408,108 @@ public struct FieldViolation: Codable, Equatable { } } +public struct GatewayDrain: Codable, Equatable { + public let version: String + public let gatewayId: String + public let sequence: Int64 + public let reason: String + public let deadline: String + enum CodingKeys: String, CodingKey { + case version = "version" + case gatewayId = "gateway_id" + case sequence = "sequence" + case reason = "reason" + case deadline = "deadline" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + gatewayId = try c.decode(String.self, forKey: .gatewayId) + sequence = try c.decode(Int64.self, forKey: .sequence) + reason = try c.decode(String.self, forKey: .reason) + deadline = try c.decode(String.self, forKey: .deadline) + } +} + +public struct GatewayHeartbeat: Codable, Equatable { + public let version: String + public let gatewayId: String + public let sequence: Int64 + public let observedAt: String + public let activeConnections: Int64 + public let egressKbps: Int64 + public let state: String + enum CodingKeys: String, CodingKey { + case version = "version" + case gatewayId = "gateway_id" + case sequence = "sequence" + case observedAt = "observed_at" + case activeConnections = "active_connections" + case egressKbps = "egress_kbps" + case state = "state" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + gatewayId = try c.decode(String.self, forKey: .gatewayId) + sequence = try c.decode(Int64.self, forKey: .sequence) + observedAt = try c.decode(String.self, forKey: .observedAt) + activeConnections = try c.decode(Int64.self, forKey: .activeConnections) + egressKbps = try c.decode(Int64.self, forKey: .egressKbps) + state = try c.decode(String.self, forKey: .state) + } +} + +public struct GatewayRegistration: Codable, Equatable { + public let version: String + public let gatewayId: String + public let instanceIdentity: String + public let certificateIdentity: String + public let publicIdentity: String + public let address: String + public let providerIdentity: String + public let protocolMinVersion: Int64 + public let protocolMaxVersion: Int64 + public let connectionCapacity: Int64 + public let bandwidthCapacityKbps: Int64 + public let features: [String] + public let capabilities: CapabilityProfile + enum CodingKeys: String, CodingKey { + case version = "version" + case gatewayId = "gateway_id" + case instanceIdentity = "instance_identity" + case certificateIdentity = "certificate_identity" + case publicIdentity = "public_identity" + case address = "address" + case providerIdentity = "provider_identity" + case protocolMinVersion = "protocol_min_version" + case protocolMaxVersion = "protocol_max_version" + case connectionCapacity = "connection_capacity" + case bandwidthCapacityKbps = "bandwidth_capacity_kbps" + case features = "features" + case capabilities = "capabilities" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + gatewayId = try c.decode(String.self, forKey: .gatewayId) + instanceIdentity = try c.decode(String.self, forKey: .instanceIdentity) + certificateIdentity = try c.decode(String.self, forKey: .certificateIdentity) + publicIdentity = try c.decode(String.self, forKey: .publicIdentity) + address = try c.decode(String.self, forKey: .address) + providerIdentity = try c.decode(String.self, forKey: .providerIdentity) + protocolMinVersion = try c.decode(Int64.self, forKey: .protocolMinVersion) + protocolMaxVersion = try c.decode(Int64.self, forKey: .protocolMaxVersion) + connectionCapacity = try c.decode(Int64.self, forKey: .connectionCapacity) + bandwidthCapacityKbps = try c.decode(Int64.self, forKey: .bandwidthCapacityKbps) + features = try c.decode([String].self, forKey: .features) + capabilities = try c.decode(CapabilityProfile.self, forKey: .capabilities) + } +} + public struct GrantReference: Codable, Equatable { public let opaqueValue: String public let expiresAt: String @@ -492,6 +654,30 @@ public struct PageInfo: Codable, Equatable { } } +public struct ProviderState: Codable, Equatable { + public let version: String + public let sessionId: String + public let state: String + public let cleanupPending: Bool + public let channels: [String] + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case state = "state" + case cleanupPending = "cleanup_pending" + case channels = "channels" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + sessionId = try c.decode(String.self, forKey: .sessionId) + state = try c.decode(String.self, forKey: .state) + cleanupPending = try c.decode(Bool.self, forKey: .cleanupPending) + channels = try c.decode([String].self, forKey: .channels) + } +} + public struct ReauthGrant: Codable, Equatable { public let token: String public let purpose: String @@ -624,6 +810,42 @@ public struct ResourceList: Codable, Equatable { } } +public struct SessionAuthority: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let audience: String + public let reconnectSequence: Int64 + public let expiresAt: String + public let capabilities: CapabilityProfile + public let providerProfile: String + public let providerIdentity: String + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case audience = "audience" + case reconnectSequence = "reconnect_sequence" + case expiresAt = "expires_at" + case capabilities = "capabilities" + case providerProfile = "provider_profile" + case providerIdentity = "provider_identity" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + sessionId = try c.decode(String.self, forKey: .sessionId) + gatewayId = try c.decode(String.self, forKey: .gatewayId) + audience = try c.decode(String.self, forKey: .audience) + reconnectSequence = try c.decode(Int64.self, forKey: .reconnectSequence) + expiresAt = try c.decode(String.self, forKey: .expiresAt) + capabilities = try c.decode(CapabilityProfile.self, forKey: .capabilities) + providerProfile = try c.decode(String.self, forKey: .providerProfile) + providerIdentity = try c.decode(String.self, forKey: .providerIdentity) + } +} + public struct SessionRequest: Codable, Equatable { public let clientDeviceId: String public let deviceKeyId: String @@ -648,6 +870,60 @@ public struct SessionRequest: Codable, Equatable { } } +public struct StableError: Codable, Equatable { + public let version: String + public let code: String + public let message: String + public let retryable: Bool + enum CodingKeys: String, CodingKey { + case version = "version" + case code = "code" + case message = "message" + case retryable = "retryable" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + code = try c.decode(String.self, forKey: .code) + message = try c.decode(String.self, forKey: .message) + retryable = try c.decode(Bool.self, forKey: .retryable) + } +} + +public struct TunnelAdmissionRequest: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let audience: String + public let grant: String + public let reconnectSequence: Int64 + public let clientNonce: String + public let capabilities: CapabilityProfile + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case audience = "audience" + case grant = "grant" + case reconnectSequence = "reconnect_sequence" + case clientNonce = "client_nonce" + case capabilities = "capabilities" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decode(String.self, forKey: .version) + sessionId = try c.decode(String.self, forKey: .sessionId) + gatewayId = try c.decode(String.self, forKey: .gatewayId) + audience = try c.decode(String.self, forKey: .audience) + grant = try c.decode(String.self, forKey: .grant) + reconnectSequence = try c.decode(Int64.self, forKey: .reconnectSequence) + clientNonce = try c.decode(String.self, forKey: .clientNonce) + capabilities = try c.decode(CapabilityProfile.self, forKey: .capabilities) + } +} + public struct VersionNegotiation: Codable, Equatable { public let supportedVersions: [String] public let features: [String] diff --git a/openspec/changes/gateway-transport-and-admission/design.md b/openspec/changes/gateway-transport-and-admission/design.md new file mode 100644 index 0000000..4b8f2d2 --- /dev/null +++ b/openspec/changes/gateway-transport-and-admission/design.md @@ -0,0 +1,24 @@ +## Decisions + +- Use the existing generated JSON binding pipeline and add only object definitions that + consumers need now. +- Keep registration/authority messages distinct from `ConnectionManifest`; the latter is + client-facing and contains no provider route, certificate, identity, or credential. +- Encode capability domains as bounded registered strings (`transport`, `framing`, `media`, + `audio`, `source_rate_control`, `client_decode`) so unknown or empty required domains + cannot silently fall back. +- Use a fixed 21-byte big-endian datagram header with application flow IDs and a payload + limit below the path MTU; control channels 1-3 remain compatible with Phase 3A. + +## Bounds and failure behavior + +All arrays, strings, payloads, fragments, and timestamps are bounded by the JSON schema or +frame registry. Generated decoders reject unknown fields, trailing values, invalid versions, +and missing required fields. Provider address, RTSP, credential, and private-key names are +not added to client-facing definitions. + +## Compatibility + +The current wire version remains `1`; current, N-1, and N-2 declarations remain unchanged. +New identifiers are additive. Consumers must reject an unknown major or no-overlap selection +before provider launch. diff --git a/openspec/changes/gateway-transport-and-admission/proposal.md b/openspec/changes/gateway-transport-and-admission/proposal.md new file mode 100644 index 0000000..e57e860 --- /dev/null +++ b/openspec/changes/gateway-transport-and-admission/proposal.md @@ -0,0 +1,32 @@ +## Why + +Phase 3A defines gateway-only manifests and control datagrams but has no versioned +contracts for the separately deployed gateway's registration, admission authority, +capability intersection, lifecycle, or encoded relay framing. Phase 3C-G needs those +contracts frozen before Data Plane or Connection Server consumers change. + +## What Changes + +- Add bounded JSON bindings for gateway registration, heartbeat/drain, tunnel admission, + session-scoped authority, capability selection, channel framing, provider state, and + stable errors. +- Extend the tunnel protobuf descriptor with the same versioned control messages. +- Register only gateway-owned media/input flow identifiers; keep provider endpoints and + credentials out of client-facing manifests. + +## Ownership and provenance + +Protocol owns the wire contract. The contract is original VerseVDI work derived from the +Phase 3A schemas and public Apollo/Moonlight behavior recorded in the Planning Hub. No +GPL source is copied into this repository. + +## Non-goals + +- Provider implementation, database policy, QUIC implementation, media decoding, or client + rendering. +- Direct client-to-Apollo routing, provider endpoint exposure, or a speculative plugin ABI. + +## Stop conditions + +Unknown versions, malformed bounds, no capability overlap, downgrade without explicit +acknowledgement, forbidden provider fields, and oversized frames fail closed. diff --git a/openspec/changes/gateway-transport-and-admission/specs/gateway-transport-and-admission/spec.md b/openspec/changes/gateway-transport-and-admission/specs/gateway-transport-and-admission/spec.md new file mode 100644 index 0000000..c8db0e4 --- /dev/null +++ b/openspec/changes/gateway-transport-and-admission/specs/gateway-transport-and-admission/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Versioned gateway authority contracts +Gateway registration, heartbeat, drain, admission, capability selection, channel framing, +provider state, and stable errors SHALL use bounded versioned objects with strict decoding. + +#### Scenario: Unknown or malformed gateway message +- **WHEN** a consumer receives an unknown version, missing required field, unknown field, + invalid bound, or trailing JSON value +- **THEN** it rejects the message before allocating provider or media state with a stable + validation error. + +### Requirement: Gateway-only client manifest +Client-facing manifests SHALL expose only the selected gateway, tunnel/profile identifiers, +policy bounds, opaque grant, audience, expiry, session, and correlation data. + +#### Scenario: Provider route injection +- **WHEN** a manifest or client-facing authority contains a provider address, RTSP URL, + certificate, pairing identity, credential, or private key field +- **THEN** schema validation rejects it and no generated client binding accepts it. + +### Requirement: Explicit capability intersection +Transport, framing, media, audio, source-rate-control, and client-decode capabilities SHALL +remain separate and no-overlap or unknown-required-profile results SHALL fail closed. + +#### Scenario: No capability overlap +- **WHEN** policy, gateway, provider, and client capabilities have no allowed intersection +- **THEN** negotiation returns a stable no-overlap error before provider launch or media flow. + +### Requirement: Bounded encoded datagrams +Encoded media and approved sequenced input SHALL use registered application flow identifiers, +validated fixed headers, bounded fragments, and payload bytes that are not codec-transformed. + +#### Scenario: Malformed or oversized datagram +- **WHEN** a datagram is truncated, has an unknown flow, invalid fragment, length mismatch, + or exceeds its registered payload limit +- **THEN** the datagram is rejected without allocation proportional to the claimed payload. diff --git a/openspec/changes/gateway-transport-and-admission/tasks.md b/openspec/changes/gateway-transport-and-admission/tasks.md new file mode 100644 index 0000000..ce4e29a --- /dev/null +++ b/openspec/changes/gateway-transport-and-admission/tasks.md @@ -0,0 +1,5 @@ +- [x] Add versioned gateway and authority definitions to the schema and tunnel descriptor. +- [x] Add registered media/audio/sequenced-input flow identifiers and positive/negative fixtures. +- [x] Regenerate Go/Rust/Swift bindings and descriptor outputs. +- [x] Run strict validation, cross-language conformance, and deterministic fixture hashing. +- [ ] Freeze the local Protocol candidate commit and record its hash for consumers. diff --git a/proto/versevdi/tunnel/v1/tunnel.proto b/proto/versevdi/tunnel/v1/tunnel.proto index 502869e..ceab96f 100644 --- a/proto/versevdi/tunnel/v1/tunnel.proto +++ b/proto/versevdi/tunnel/v1/tunnel.proto @@ -49,3 +49,95 @@ message ChannelOpen { string direction = 2; uint32 maximum_frame_bytes = 3; } + +message CapabilityProfile { + string transport = 1; + string framing = 2; + string media = 3; + string audio = 4; + string source_rate_control = 5; + string client_decode = 6; +} + +message GatewayRegistration { + string version = 1; + string gateway_id = 2; + string instance_identity = 3; + string certificate_identity = 4; + string public_identity = 5; + string address = 6; + uint32 protocol_min_version = 7; + uint32 protocol_max_version = 8; + uint32 connection_capacity = 9; + uint64 bandwidth_capacity_kbps = 10; + repeated string features = 11; + CapabilityProfile capabilities = 12; + string provider_identity = 13; +} + +message GatewayHeartbeat { + string version = 1; + string gateway_id = 2; + uint64 sequence = 3; + google.protobuf.Timestamp observed_at = 4; + uint32 active_connections = 5; + uint64 egress_kbps = 6; + string state = 7; +} + +message GatewayDrain { + string version = 1; + string gateway_id = 2; + uint64 sequence = 3; + string reason = 4; + google.protobuf.Timestamp deadline = 5; +} + +message TunnelAdmissionRequest { + string version = 1; + string session_id = 2; + string gateway_id = 3; + string audience = 4; + string grant = 5; + uint64 reconnect_sequence = 6; + string client_nonce = 7; + CapabilityProfile capabilities = 8; +} + +message SessionAuthority { + string version = 1; + string session_id = 2; + string gateway_id = 3; + string audience = 4; + uint64 reconnect_sequence = 5; + google.protobuf.Timestamp expires_at = 6; + CapabilityProfile capabilities = 7; + string provider_profile = 8; + string provider_identity = 9; +} + +message ChannelFrame { + string version = 1; + string flow_id = 2; + uint64 sequence = 3; + uint32 flags = 4; + uint32 fragment_index = 5; + uint32 fragment_count = 6; + uint64 timestamp_ms = 7; + bytes payload = 8; +} + +message ProviderState { + string version = 1; + string session_id = 2; + string state = 3; + bool cleanup_pending = 4; + repeated string channels = 5; +} + +message StableError { + string version = 1; + string code = 2; + string message = 3; + bool retryable = 4; +} diff --git a/registries/datagrams.json b/registries/datagrams.json index 9110e68..0d08aa9 100644 --- a/registries/datagrams.json +++ b/registries/datagrams.json @@ -4,6 +4,9 @@ "datagrams": [ {"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024}, {"id": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048}, - {"id": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65536} + {"id": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65536}, + {"id": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1200}, + {"id": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1200}, + {"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200} ] } diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 28fd2df..37dc463 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -343,6 +343,134 @@ "supported_versions": {"type": "array", "minItems": 1, "maxItems": 3, "items": {"type": "string", "maxLength": 16}}, "features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 64}} } + }, + "CapabilityProfile": { + "type": "object", + "additionalProperties": false, + "required": ["transport", "framing", "media", "audio", "source_rate_control", "client_decode"], + "properties": { + "transport": {"type": "string", "minLength": 1, "maxLength": 64}, + "framing": {"type": "string", "minLength": 1, "maxLength": 64}, + "media": {"type": "string", "minLength": 1, "maxLength": 64}, + "audio": {"type": "string", "minLength": 1, "maxLength": 64}, + "source_rate_control": {"type": "string", "minLength": 1, "maxLength": 64}, + "client_decode": {"type": "string", "minLength": 1, "maxLength": 64} + } + }, + "GatewayRegistration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "gateway_id", "instance_identity", "certificate_identity", "public_identity", "address", "provider_identity", "protocol_min_version", "protocol_max_version", "connection_capacity", "bandwidth_capacity_kbps", "features", "capabilities"], + "properties": { + "version": {"type": "string", "const": "1"}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "instance_identity": {"type": "string", "minLength": 1, "maxLength": 512}, + "certificate_identity": {"type": "string", "minLength": 1, "maxLength": 512}, + "public_identity": {"type": "string", "minLength": 1, "maxLength": 256}, + "address": {"type": "string", "minLength": 1, "maxLength": 256}, + "provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}, + "protocol_min_version": {"type": "integer", "minimum": 1, "maximum": 100}, + "protocol_max_version": {"type": "integer", "minimum": 1, "maximum": 100}, + "connection_capacity": {"type": "integer", "minimum": 1, "maximum": 1000000}, + "bandwidth_capacity_kbps": {"type": "integer", "minimum": 1, "maximum": 1000000000}, + "features": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 64}}, + "capabilities": {"$ref": "#/$defs/CapabilityProfile"} + } + }, + "GatewayHeartbeat": { + "type": "object", + "additionalProperties": false, + "required": ["version", "gateway_id", "sequence", "observed_at", "active_connections", "egress_kbps", "state"], + "properties": { + "version": {"type": "string", "const": "1"}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "sequence": {"type": "integer", "minimum": 1}, + "observed_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "active_connections": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "egress_kbps": {"type": "integer", "minimum": 0, "maximum": 1000000000}, + "state": {"type": "string", "enum": ["ready", "draining", "offline"]} + } + }, + "GatewayDrain": { + "type": "object", + "additionalProperties": false, + "required": ["version", "gateway_id", "sequence", "reason", "deadline"], + "properties": { + "version": {"type": "string", "const": "1"}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "sequence": {"type": "integer", "minimum": 1}, + "reason": {"type": "string", "minLength": 1, "maxLength": 256}, + "deadline": {"type": "string", "format": "date-time", "maxLength": 64} + } + }, + "TunnelAdmissionRequest": { + "type": "object", + "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "capabilities"], + "properties": { + "version": {"type": "string", "const": "1"}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "audience": {"type": "string", "minLength": 1, "maxLength": 256}, + "grant": {"type": "string", "minLength": 43, "maxLength": 256}, + "reconnect_sequence": {"type": "integer", "minimum": 0}, + "client_nonce": {"type": "string", "minLength": 16, "maxLength": 128}, + "capabilities": {"$ref": "#/$defs/CapabilityProfile"} + } + }, + "SessionAuthority": { + "type": "object", + "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "provider_profile", "provider_identity"], + "properties": { + "version": {"type": "string", "const": "1"}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "audience": {"type": "string", "minLength": 1, "maxLength": 256}, + "reconnect_sequence": {"type": "integer", "minimum": 0}, + "expires_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "capabilities": {"$ref": "#/$defs/CapabilityProfile"}, + "provider_profile": {"type": "string", "enum": ["apollo"]}, + "provider_identity": {"type": "string", "minLength": 1, "maxLength": 256} + } + }, + "ChannelFrame": { + "type": "object", + "additionalProperties": false, + "required": ["version", "flow_id", "sequence", "flags", "fragment_index", "fragment_count", "timestamp_ms", "payload"], + "properties": { + "version": {"type": "string", "const": "1"}, + "flow_id": {"type": "string", "minLength": 1, "maxLength": 64}, + "sequence": {"type": "integer", "minimum": 0}, + "flags": {"type": "integer", "minimum": 0, "maximum": 255}, + "fragment_index": {"type": "integer", "minimum": 0, "maximum": 15}, + "fragment_count": {"type": "integer", "minimum": 1, "maximum": 16}, + "timestamp_ms": {"type": "integer", "minimum": 0}, + "payload": {"type": "string", "maxLength": 87384, "x-max-bytes": 65536} + } + }, + "ProviderState": { + "type": "object", + "additionalProperties": false, + "required": ["version", "session_id", "state", "cleanup_pending", "channels"], + "properties": { + "version": {"type": "string", "const": "1"}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "state": {"type": "string", "enum": ["starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"]}, + "cleanup_pending": {"type": "boolean"}, + "channels": {"type": "array", "maxItems": 8, "items": {"type": "string", "minLength": 1, "maxLength": 64}} + } + }, + "StableError": { + "type": "object", + "additionalProperties": false, + "required": ["version", "code", "message", "retryable"], + "properties": { + "version": {"type": "string", "const": "1"}, + "code": {"type": "string", "minLength": 1, "maxLength": 128}, + "message": {"type": "string", "minLength": 1, "maxLength": 512}, + "retryable": {"type": "boolean"} + } } } } diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index 750ce6d..8291bae 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -35,3 +35,28 @@ func TestGeneratedDecodersRejectMissingRequiredFieldsAndTrailingValues(t *testin t.Fatal("DecodeErrorEnvelope accepted a missing required boolean") } } + +func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) { + registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":1,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":"h264-opus"}}` + if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err != nil { + t.Fatalf("valid gateway registration rejected: %v", err) + } + for _, invalid := range []string{ + strings.Replace(registration, `"version":"1"`, `"version":"2"`, 1), + strings.Replace(registration, `"features":["datagram.media"]`, `"features":["datagram.media"],"provider_url":"https://provider.invalid"`, 1), + } { + if _, err := protocol.DecodeGatewayRegistration([]byte(invalid)); err == nil { + t.Fatalf("invalid gateway registration accepted: %s", invalid) + } + } +} + +func TestSessionAuthorityRejectsProviderRoute(t *testing.T) { + valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","audience":"versevdi-gateway","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":"h264-opus"},"provider_profile":"apollo","provider_identity":"provider-1"}` + if _, err := protocol.DecodeSessionAuthority([]byte(valid)); err != nil { + t.Fatalf("valid session authority rejected: %v", err) + } + if _, err := protocol.DecodeSessionAuthority([]byte(strings.Replace(valid, `"provider_identity":"provider-1"`, `"provider_identity":"provider-1","rtsp_url":"rtsp://provider.invalid"`, 1))); err == nil { + t.Fatal("session authority accepted a provider route") + } +} diff --git a/tools/go-conformance/main.go b/tools/go-conformance/main.go index fcbace4..940e91e 100644 --- a/tools/go-conformance/main.go +++ b/tools/go-conformance/main.go @@ -157,7 +157,7 @@ func classifyDatagram(encoded string) string { if raw[2] != 1 { return "invalid:unsupported_version" } - limits := map[byte]int{1: 1024, 2: 2048, 3: 65515} + limits := map[byte]int{1: 1024, 2: 2048, 3: 65515, 10: 1179, 11: 1179, 12: 1179} limit, ok := limits[raw[3]] if !ok { return "invalid:unknown_channel" diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index ac18f88..273a4f7 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -85,6 +85,7 @@ fn classify_datagram(encoded: &str) -> &'static str { 1 => 1024, 2 => 2048, 3 => 65515, + 10 | 11 | 12 => 1179, _ => return "invalid:unknown_channel", }; if raw[4] != 0 { diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index b0b7999..8e8d639 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -50,6 +50,7 @@ func classifyDatagram(_ encoded: String) -> String { case 1: limit = 1024 case 2: limit = 2048 case 3: limit = 65515 + case 10, 11, 12: limit = 1179 default: return "invalid:unknown_channel" } guard raw[4] == 0 else { return "invalid:flags" } diff --git a/tools/validate_frames.py b/tools/validate_frames.py index 73a9096..a886bb6 100644 --- a/tools/validate_frames.py +++ b/tools/validate_frames.py @@ -9,7 +9,7 @@ import pathlib ROOT = pathlib.Path(__file__).resolve().parents[1] HEADER_BYTES = 21 MAX_FRAME_BYTES = 65536 -CHANNEL_LIMITS = {1: 1024, 2: 2048, 3: 65515} +CHANNEL_LIMITS = {1: 1024, 2: 2048, 3: 65515, 10: 1179, 11: 1179, 12: 1179} def classify(raw: bytes) -> str: