diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 31a4ab42..c8693d68 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -204,6 +204,14 @@ namespace GameCult.Mesh return new CultMeshPeerCatalog(); } + /// + /// Creates a local authority lease catalog. + /// + public static CultMeshAuthorityLeaseCatalog CreateAuthorityLeaseCatalog() + { + return new CultMeshAuthorityLeaseCatalog(); + } + /// /// Attaches Verse discovery responses to a CultMesh node. /// diff --git a/src/GameCult.Mesh/CultMeshAuthority.cs b/src/GameCult.Mesh/CultMeshAuthority.cs new file mode 100644 index 00000000..ed39b18f --- /dev/null +++ b/src/GameCult.Mesh/CultMeshAuthority.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GameCult.Mesh +{ + /// + /// Grants one peer authority to perform roles for a Verse over a bounded interval. + /// + public sealed class CultMeshAuthorityLease + { + /// + /// Creates an authority lease. + /// + public CultMeshAuthorityLease( + string leaseId, + string verseId, + string peerId, + IEnumerable roles, + IEnumerable? shardIds, + string issuerRuntimeId, + DateTimeOffset validFrom, + DateTimeOffset expiresAt, + string? signature = null) + { + LeaseId = RequireNonEmpty(leaseId, nameof(leaseId)); + VerseId = RequireNonEmpty(verseId, nameof(verseId)); + PeerId = RequireNonEmpty(peerId, nameof(peerId)); + Roles = Clean(roles); + ShardIds = Clean(shardIds); + IssuerRuntimeId = RequireNonEmpty(issuerRuntimeId, nameof(issuerRuntimeId)); + ValidFrom = validFrom; + ExpiresAt = expiresAt; + Signature = signature; + if (expiresAt <= validFrom) + { + throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt)); + } + } + + /// Gets the stable lease id. + public string LeaseId { get; } + /// Gets the Verse id governed by the lease. + public string VerseId { get; } + /// Gets the peer id receiving authority. + public string PeerId { get; } + /// Gets the roles granted by this lease. + public IReadOnlyList Roles { get; } + /// Gets shard ids covered by this lease. Empty means every shard in the Verse. + public IReadOnlyList ShardIds { get; } + /// Gets the runtime that issued the lease. + public string IssuerRuntimeId { get; } + /// Gets the first timestamp when the lease is valid. + public DateTimeOffset ValidFrom { get; } + /// Gets the timestamp when the lease expires. + public DateTimeOffset ExpiresAt { get; } + /// Gets an optional signature over the lease. + public string? Signature { get; } + + /// + /// Returns whether this lease is valid at the supplied time. + /// + public bool IsValidAt(DateTimeOffset at) + { + return at >= ValidFrom && at < ExpiresAt; + } + + /// + /// Returns whether this lease covers a peer card, role, and shard. + /// + public bool Covers(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null) + { + if (peer == null) throw new ArgumentNullException(nameof(peer)); + if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role)); + var time = at ?? DateTimeOffset.UtcNow; + return IsValidAt(time) && + string.Equals(VerseId, peer.VerseId, StringComparison.Ordinal) && + string.Equals(PeerId, peer.PeerId, StringComparison.Ordinal) && + string.Equals(LeaseId, peer.AuthorityLeaseId, StringComparison.Ordinal) && + Roles.Contains(role, StringComparer.Ordinal) && + peer.Roles.Contains(role, StringComparer.Ordinal) && + (string.IsNullOrWhiteSpace(shardId) || + ShardIds.Count == 0 || + ShardIds.Contains(shardId!, StringComparer.Ordinal)); + } + + private static string[] Clean(IEnumerable? values) + { + return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() + ?? Array.Empty(); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Local authority lease catalog. + /// + public sealed class CultMeshAuthorityLeaseCatalog + { + private readonly Dictionary _leases = new(StringComparer.Ordinal); + + /// Gets all known leases. + public IReadOnlyList Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray(); + + /// Adds or replaces an authority lease. + public void Upsert(CultMeshAuthorityLease lease) + { + if (lease == null) throw new ArgumentNullException(nameof(lease)); + _leases[lease.LeaseId] = lease; + } + + /// Gets a lease by id, if known. + public CultMeshAuthorityLease? Get(string leaseId) + { + if (string.IsNullOrWhiteSpace(leaseId)) throw new ArgumentException("Value must be non-empty.", nameof(leaseId)); + return _leases.TryGetValue(leaseId, out var lease) ? lease : null; + } + + /// + /// Returns whether a peer card is authorized for a role and optional shard. + /// + public bool IsAuthorized(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null) + { + if (peer == null) throw new ArgumentNullException(nameof(peer)); + if (string.IsNullOrWhiteSpace(peer.AuthorityLeaseId)) + { + return false; + } + + return _leases.TryGetValue(peer.AuthorityLeaseId!, out var lease) && + lease.Covers(peer, role, shardId, at); + } + } +} diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index cf99b835..18271cd1 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -92,6 +92,20 @@ Peer cards are contact candidates, not authority by themselves. Public Verses should validate authority leases or signatures before trusting a peer for committed state. +```csharp +var leases = CultMesh.CreateAuthorityLeaseCatalog(); +leases.Upsert(new CultMeshAuthorityLease( + "lease:players-us-east:42", + "aetheria-main", + "gc-us-east-1", + [CultMeshPeerRoles.ShardPrimary], + ["players-us-east"], + "gamecult-operator", + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddMinutes(10), + signature: "operator-signature")); +``` + Clients can fetch more peers from known peers: ```csharp diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 92664d7e..1a3dc076 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -14,6 +14,7 @@ Namespace: `GameCult.Mesh` - `CultMesh.CreatePeerCatalog()` - `CultMesh.CreateVerseDiscoveryClient(...)` - `CultMesh.CreatePeerExchangeClient(...)` +- `CultMesh.CreateAuthorityLeaseCatalog()` - `CultMesh.CreateClient(...)` - `CultMesh.ConnectClient(...)` @@ -66,6 +67,14 @@ upsert them into a local catalog. Peer cards are discovery hints; authority still requires a valid lease or signature for the target Verse. +### Authority Leases + +`CultMeshAuthorityLease` grants one peer specific roles for a Verse, optionally +scoped to shard ids and bounded by time. `CultMeshAuthorityLeaseCatalog` checks +whether a peer card is authorized for a requested role and shard. This keeps +peer exchange separate from authority instead of letting contact gossip mutate +the world. + ### Shard Authority Each shard has one primary writer for now. Non-primary writes are rejected or diff --git a/src/GameCult.Mesh/docs/verses.md b/src/GameCult.Mesh/docs/verses.md index b0588d5f..656dcfaf 100644 --- a/src/GameCult.Mesh/docs/verses.md +++ b/src/GameCult.Mesh/docs/verses.md @@ -64,3 +64,8 @@ primary shard, replica, read replica, and simulation observer roles, but those cards do not grant authority by themselves. Public operator Verses should treat authority lease ids and signatures as mandatory before accepting committed state from a peer. + +Authority leases are the narrow bridge between discovery and trust. A lease +binds a peer id to a Verse, role, shard scope, issuer, validity window, and +signature. Peer exchange can spread cards quickly; lease validation decides +whether a card is allowed to touch committed state. diff --git a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md index ed93a567..ddbe3498 100644 --- a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md +++ b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md @@ -74,6 +74,8 @@ Implemented: - schema-v0 peer exchange request/response messages - CultMesh peer cards, peer catalog, peer exchange server bridge, and peer exchange client +- CultMesh authority leases and lease catalog checks for peer role/shard + authorization - client-authority scopes for locally predicted input documents - predicted and reconciled database change kinds for client-side prediction - simulation witness observations for mesh-side opinions about frame facts @@ -123,12 +125,16 @@ Not implemented yet: until a shard owner writes them into the log. - Peer cards are contact candidates. They can advertise roles and authority lease ids, but they do not grant authority without Verse-specific validation. +- Authority leases bind peer id, Verse id, roles, optional shard scope, issuer, + validity window, and signature. They are the first trust boundary above peer + exchange. ## Next Coherent Slice Add discovery fanout and operational polish: - add periodic gossip fanout over known peer cards +- add real signature verification for authority leases - add snapshot throttling and size limits for public mesh edges - after that, add simulation-frame rollback/resimulation helpers around predicted input streams diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index b23396d0..3d59346a 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1020,6 +1020,35 @@ namespace GameCult.Networking.Tests Assert.That(updates, Has.Count.EqualTo(1)); } + [Test] + public void CultMeshAuthorityLeaseCatalog_Authorizes_PeerRoleAndShard() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00.0000000Z"); + var catalog = CultMesh.CreateAuthorityLeaseCatalog(); + var peer = new CultMeshPeerCard( + "peer-primary", + "aetheria-main", + ["cultnet://primary.example.test:3075"], + roles: [CultMeshPeerRoles.ShardPrimary], + shardIds: ["players-us-east"], + authorityLeaseId: "lease-primary"); + catalog.Upsert(new CultMeshAuthorityLease( + "lease-primary", + "aetheria-main", + "peer-primary", + [CultMeshPeerRoles.ShardPrimary], + ["players-us-east"], + "gc-operator", + now.AddMinutes(-5), + now.AddMinutes(5), + signature: "sig")); + + Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now), Is.True); + Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-eu", now), Is.False); + Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ReadReplica, "players-us-east", now), Is.False); + Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now.AddMinutes(6)), Is.False); + } + [Test] public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath() {