diff --git a/README.md b/README.md index dc155de..2a56151 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RelayControl Shared control plane for SonicRelay and FrameRelay. RelayControl provides device identity, pairing, sessions, join codes, authenticated WebSocket signaling and TURN credential issuance using ASP.NET Core Minimal API, PostgreSQL and Redis. WebRTC media stays between clients, directly or through coturn; this API never carries, transcodes or stores the media itself. - + ## Project suite | Project | Repository | Stack | Responsibility | diff --git a/docs/discord-activity-launch-contract.md b/docs/discord-activity-launch-contract.md new file mode 100644 index 0000000..bfb4892 --- /dev/null +++ b/docs/discord-activity-launch-contract.md @@ -0,0 +1,79 @@ +# FrameRelay launch and Discord Activity contract + +Configure `RelayLaunch:ServiceToken` (a separate high entropy bot-only bearer), `PublicBaseUrl` +(public HTTPS API origin), `DownloadUrl`, `DiscordClientId`, `DiscordClientSecret`, +`DiscordBotToken` and optional `DiscordRedirectUri`. Keep both Discord secrets API-side. +Apply `AddLaunchCapabilities` and `EnforceActivityInstanceBinding` before enabling bot routes. Existing device session routes remain +DeviceBearer authenticated. Bot credentials cannot create/join sessions. + +All bot operations use `Authorization: Bearer `: + +| Request | Body / query | Response | +|---|---|---| +| POST /api/launch-intents/share | `{provider:"discord",guildId,channelId,requestedByUserId,ttlSeconds}` | `{id,launchUrl,expiresAt}` | +| GET /api/launch-intents/pending | none | `[{id,guildId,channelId,requestedByUserId,status,expiresAt}]` | +| GET /api/launch-intents/{id} | `watchTtlSeconds` optional | `{id,status,sessionId,watchLaunchUrl,expiresAt}` | +| POST /api/launch-intents/{id}/published | `{messageId?}` | 204 | +| POST /api/launch-intents/watch | `{code,ttlSeconds}` | `{launchUrl,expiresAt}` | +| POST /api/launch-intents/activity | `{code,guildId,channelId,requestedByUserId,ttlSeconds}` | `{id,expiresAt}` | + +TTL is clamped to 30–900 seconds. Share status is `pending` or `session_ready`, matching the Go watcher contract. Capability tokens +are 64 lowercase hex characters and only hashes are stored; Activity bootstrap tokens are +retained exclusively server-side. Watch only admits live screen-share codes. + +`GET /api/launch-intents/{id}?watchTtlSeconds=0` opts out of creating a watch capability: +it returns the existing ready/status/session fields and `watchLaunchUrl: null`. Omitted TTL +still creates a 300-second watch capability; positive TTL keeps the normal clamped behavior. +The bot uses zero during polling because a public ready announcement cannot share a +single-use capability among participants. Each participant runs `/framerelay watch ` +to obtain an individual Activity intent and individual desktop fallback, so status polling +does not create unused credentials. + +Public URLs are `/open/launch#`: the fragment is absent from HTTP access logs. The landing +page clears it from history and opens `framerelay://launch?token=`, and shows the configured +download/help link. The operating system cannot reliably report whether a protocol handler is +installed, so the download/help link stays visible rather than claiming automatic detection. + +Desktop uses its own DeviceBearer for `POST /api/launch-intents/redeem {token}` → +`{id,kind,code,sessionId}`. Redemption is single-use. `kind=share` starts the normal screen +session, then `POST /api/launch-intents/{id}/bind {sessionId}` → 204; API checks both the +redeeming device and session source ownership. `kind=watch` uses `code` where present or the +sessionId for an owner-announcement watch link. No bot-supplied session ownership is trusted. + +The Activity SDK authorizes with `identify`. It submits its authorization code and SDK instance: + +1. `POST /api/discord/activity/authorize {code,instanceId}` → + `{accessToken,userId,instanceId,expiresAt}`. This accessToken is an opaque FrameRelay identity + credential, never the Discord OAuth token. API exchanges the code and calls Discord `/users/@me`, + then bot-authenticated `GET /applications/{application.id}/activity-instances/{instance_id}`. + Application, instance, current user membership, guild and channel must match. The invoking user + binds the newest pending context intent once; later users may authenticate in that bound instance. + An instance can bind one active session. A new watch for a different session returns 409 + `activity_instance_busy` while that session remains live, so it cannot silently show old media. + When the previous session ends or the binding expires, a new pending intent can replace it; + replacement expires all old instance/session credentials. A filtered unique database index, + instance lock and serializable transaction protect concurrent initial binding. +2. `POST /api/discord/activity/viewer-grants` with identity bearer and no body → `{grant,expiresAt}`. + Revalidates current Discord membership. Grant lasts 60 seconds and works once. +3. `POST /api/discord/activity/viewer-grants/redeem {grant}` → + `{sessionId,participantId,signalingToken,expiresAt,iceServers}`. Counts the participant against + the existing session viewer limit. No host/device credential or participant selector is accepted. +4. Connect `/ws/signaling?sessionId=` with WebSocket subprotocols + `["framerelay","token."+signalingToken]`. The token works for this session only and is single-use; + the server negotiates `framerelay`. Existing signaling envelope/SDP/ICE shapes are unchanged. + +Bound instances and viewer signaling grants last 15 minutes; sockets close at credential expiry, +session end or Activity disconnect, and expired unused viewer reservations are cleaned every 30s. +Presence is checked during signaling admission and every five seconds thereafter using one +coalesced Discord REST snapshot per instance every five seconds. A snapshot that omits users +revokes their identity/grant/signaling credentials together; their sockets close and viewer slots +are released. Discord lookup failure fails closed. Disconnected Activity viewers do not use the desktop reconnect grace period. Obtain a new grant +to reconnect while the bound Activity authorization remains valid. + +Secrets must not be included in request body or header logging. Activity requires reachable HTTPS +hosting, Discord Activities enabled, Activity URL Mapping to the client/API/WebSocket host, and +the same Discord application ID for bot, SDK and API. No RTP passes through this API. + +The official validation contract is documented at +https://docs.discord.com/developers/resources/application#get-application-activity-instance. +Deployment and real Discord/TURN/H.264/Opus playback require manual validation. diff --git a/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs b/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs new file mode 100644 index 0000000..248b23b --- /dev/null +++ b/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SonicRelay.Api.Services; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Authorization; + +public sealed class ActivityAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder, AppDbContext db, TimeProvider time, IDiscordActivityValidator discord) + : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task HandleAuthenticateAsync() + { + if (Request.Path != "/ws/signaling") return AuthenticateResult.NoResult(); + var protocol = Request.Headers.SecWebSocketProtocol.ToString().Split(',').Select(x => x.Trim()).FirstOrDefault(x => x.StartsWith("token.")); + if (protocol is null) return AuthenticateResult.NoResult(); + if (!Request.Headers.SecWebSocketProtocol.ToString().Split(',').Any(x => x.Trim() == "framerelay")) return AuthenticateResult.Fail("Missing Activity protocol."); + var cap = await RelayCapability.FindAsync(db, protocol[6..], "signaling", time.GetUtcNow(), Context.RequestAborted); + if (cap?.SessionId is not { } sessionId || cap.DeviceId is null || Request.Query["sessionId"] != sessionId.ToString()) return AuthenticateResult.Fail("Invalid Activity credential."); + if (!await db.StreamSessions.AnyAsync(x => x.Id == sessionId && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, Context.RequestAborted)) return AuthenticateResult.Fail("Session ended."); + if (!await ActivityPresenceService.IsAuthorizedAsync(db, discord, cap, time.GetUtcNow(), Context.RequestAborted)) return AuthenticateResult.Fail("Activity membership revoked."); + cap.ConsumedAt = time.GetUtcNow(); + try { await db.SaveChangesAsync(Context.RequestAborted); } catch (DbUpdateConcurrencyException) { return AuthenticateResult.Fail("Credential consumed."); } + var claims = new[] { new Claim("sub", cap.DeviceId.Value.ToString()), new Claim("activity_session", sessionId.ToString()), new Claim("activity_expiry", cap.ExpiresAt.ToUnixTimeSeconds().ToString()), new Claim("activity_capability", cap.Id.ToString()) }; + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)), Scheme.Name)); + } +} diff --git a/services/SonicRelay.Api/Authorization/DeviceScopeAuthorizationHandler.cs b/services/SonicRelay.Api/Authorization/DeviceScopeAuthorizationHandler.cs index da8dc32..bd0250a 100644 --- a/services/SonicRelay.Api/Authorization/DeviceScopeAuthorizationHandler.cs +++ b/services/SonicRelay.Api/Authorization/DeviceScopeAuthorizationHandler.cs @@ -12,6 +12,11 @@ public sealed class DeviceScopeAuthorizationHandler(AppDbContext db) : Authoriza protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, DeviceScopeRequirement requirement) { + if (requirement.Scope == "signaling:connect" && context.User.Identities.Any(x => x.AuthenticationType == "Activity")) + { + context.Succeed(requirement); + return; + } if (requirement.Scope is not null) { var scopes = context.User.FindFirstValue("scope")?.Split(' ', StringSplitOptions.RemoveEmptyEntries) diff --git a/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs new file mode 100644 index 0000000..39ff0f1 --- /dev/null +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore; +using SonicRelay.Api.Services; +using SonicRelay.Application.Abstractions; +using SonicRelay.Domain.DeviceIdentities; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Endpoints; + +public static class DiscordActivityEndpoints +{ + public sealed record AuthorizeRequest(string Code, string InstanceId); + public sealed record GrantRequest(string Grant); + public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/discord/activity").RequireRateLimiting("join-session"); + group.MapPost("/authorize", async (AuthorizeRequest request, IDiscordActivityValidator discord, AppDbContext db, IParticipantAdmissionLock admissionLock, TimeProvider time, CancellationToken ct) => + { + DiscordActivityIdentity? identity; + try { identity = await discord.AuthorizeAsync(request.Code, request.InstanceId, ct); } + catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException or TaskCanceledException) { return Results.Unauthorized(); } + if (identity is null) return Results.Unauthorized(); + var now = time.GetUtcNow(); + var instanceLock = new Guid(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(identity.InstanceId)).AsSpan(0, 16)); + using var binding = await admissionLock.AcquireAsync(instanceLock, Guid.Empty, ct); + await using var transaction = db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null; + var bound = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Kind == "activity" && x.InstanceId == identity.InstanceId, ct); + var pending = await db.LaunchCapabilities.Where(x => x.Kind == "activity" && x.InstanceId == null && x.ConsumedAt == null && x.UserId == identity.UserId && x.GuildId == identity.GuildId && x.ChannelId == identity.ChannelId && x.ExpiresAt > now).OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct); + var boundLive = bound is not null && bound.ExpiresAt > now && await LiveAsync(db, bound.SessionId, ct); + if (pending is not null && boundLive && pending.SessionId != bound!.SessionId) + return Results.Conflict(new { code = "activity_instance_busy" }); + LaunchCapability? intent; + if (pending is not null) + { + if (!await LiveAsync(db, pending.SessionId, ct)) return Results.Unauthorized(); + pending.ConsumedAt = now; + if (!boundLive) + { + if (bound is not null) + { + bound.InstanceId = null; + var stale = await db.LaunchCapabilities.Where(x => x.InstanceId == identity.InstanceId).ToListAsync(ct); + foreach (var cap in stale) cap.ExpiresAt = now; + await db.SaveChangesAsync(ct); + } + pending.InstanceId = identity.InstanceId; pending.ExpiresAt = now.AddMinutes(15); + intent = pending; + } + else intent = bound; + } + else intent = boundLive ? bound : null; + if (intent is null || intent.GuildId != identity.GuildId || intent.ChannelId != identity.ChannelId) return Results.Unauthorized(); + if (!await LiveAsync(db, intent.SessionId, ct)) return Results.Unauthorized(); + var token = RelayCapability.NewToken(); + var credential = RelayCapability.Create("identity", token, now, 900); + CopyContext(intent, credential); credential.UserId = identity.UserId; + db.LaunchCapabilities.Add(credential); + try + { + await db.SaveChangesAsync(ct); + if (transaction is not null) await transaction.CommitAsync(ct); + } + catch (DbUpdateException) { return Results.Conflict(new { code = "activity_binding_conflict" }); } + return Results.Ok(new { accessToken = token, identity.UserId, identity.InstanceId, credential.ExpiresAt }); + }); + group.MapPost("/viewer-grants", async (HttpContext context, AppDbContext db, IDiscordActivityValidator discord, TimeProvider time, CancellationToken ct) => + { + var header = context.Request.Headers.Authorization.ToString(); + var identity = await RelayCapability.FindAsync(db, header.StartsWith("Bearer ") ? header[7..] : "", "identity", time.GetUtcNow(), ct); + if (identity is null || !await ActivityPresenceService.IsAuthorizedAsync(db, discord, identity, time.GetUtcNow(), ct)) return Results.Unauthorized(); + var token = RelayCapability.NewToken(); + var grant = RelayCapability.Create("grant", token, time.GetUtcNow(), 60); + CopyContext(identity, grant); db.LaunchCapabilities.Add(grant); await db.SaveChangesAsync(ct); + return Results.Ok(new { grant = token, grant.ExpiresAt }); + }); + group.MapPost("/viewer-grants/redeem", async (GrantRequest request, AppDbContext db, IDiscordActivityValidator discord, + IParticipantAdmissionLock admissionLock, TurnCredentialService turn, TimeProvider time, CancellationToken ct) => + { + var now = time.GetUtcNow(); + var grant = await RelayCapability.FindAsync(db, request.Grant, "grant", now, ct); + if (grant?.SessionId is not { } sessionId) return Results.Unauthorized(); + using var admission = await admissionLock.AcquireAsync(sessionId, Guid.Empty, ct); + now = time.GetUtcNow(); + if (!await ActivityPresenceService.IsAuthorizedAsync(db, discord, grant, now, ct)) return Results.Unauthorized(); + await using var transaction = db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null; + var session = await db.StreamSessions.SingleOrDefaultAsync(x => x.Id == sessionId, ct); + if (session is null || session.Status is SessionStatuses.Ended or SessionStatuses.Expired) return Results.Unauthorized(); + if (await db.SessionParticipants.CountAsync(x => x.SessionId == sessionId && x.Role == ParticipantRoles.Viewer && (x.Status == ParticipantStatuses.Connected || x.Status == ParticipantStatuses.Reconnecting), ct) >= session.MaxViewers) return Results.Conflict(new { code = "viewer_limit" }); + var device = new DeviceIdentity { Id = Guid.NewGuid(), Name = "Discord Activity viewer", DeviceType = "discord_activity", Platform = "web", CreatedAt = now }; + var participant = new SessionParticipant { Id = Guid.NewGuid(), DeviceId = device.Id, SessionId = sessionId, JoinedAt = now }; + SessionAudioPolicy.ApplyDefaults(participant, SessionModes.ScreenShare); + grant.ConsumedAt = now; + var token = RelayCapability.NewToken(); + var signal = RelayCapability.Create("signaling", token, now, 900); + CopyContext(grant, signal); signal.DeviceId = device.Id; signal.ParticipantId = participant.Id; + db.DeviceIdentities.Add(device); db.SessionParticipants.Add(participant); db.LaunchCapabilities.Add(signal); + session.Status = SessionStatuses.Active; session.StartedAt ??= now; + try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { return Results.Conflict(); } + if (transaction is not null) await transaction.CommitAsync(ct); + var ice = await turn.BuildAsync(device.Id.ToString(), ct); + return Results.Ok(new { sessionId, participantId = participant.Id, signalingToken = token, signal.ExpiresAt, ice.IceServers }); + }); + return app; + } + private static Task LiveAsync(AppDbContext db, Guid? id, CancellationToken ct) => db.StreamSessions.AnyAsync(x => x.Id == id && x.Mode == SessionModes.ScreenShare && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct); + private static void CopyContext(LaunchCapability source, LaunchCapability target) + { + target.SessionId = source.SessionId; target.UserId = source.UserId; target.GuildId = source.GuildId; + target.ChannelId = source.ChannelId; target.InstanceId = source.InstanceId; + } +} diff --git a/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs new file mode 100644 index 0000000..018b521 --- /dev/null +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SonicRelay.Api.Services; +using SonicRelay.Application.Abstractions; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Endpoints; + +/// Routes used by Discord Activities and desktop redemption for Activity launch links. +public static class DiscordActivityLaunchIntentEndpoints +{ + public sealed record ActivityRequest(string Code, string GuildId, string ChannelId, + string RequestedByUserId, int TtlSeconds = 120); + public sealed record TokenRequest(string Token); + public sealed record BindRequest(Guid SessionId); + + public static IEndpointRouteBuilder MapDiscordActivityLaunchIntentEndpoints(this IEndpointRouteBuilder app) + { + var bot = app.MapGroup("/api/launch-intents").WithTags("Launch intents") + .RequireAuthorization("launch-intents:bot"); + bot.MapPost("/activity", async (ActivityRequest request, AppDbContext db, ISessionCodeStore codes, + IConfiguration config, TimeProvider time, CancellationToken ct) => + { + if (!ValidContext(request.GuildId, request.ChannelId, request.RequestedByUserId)) + return Results.BadRequest(); + var session = await ResolveSessionAsync(request.Code, db, codes, config, time.GetUtcNow(), ct); + if (session is null) return Results.NotFound(); + var token = RelayCapability.NewToken(); + var intent = RelayCapability.Create("activity", token, time.GetUtcNow(), request.TtlSeconds); + intent.SessionId = session.Id; + intent.GuildId = request.GuildId; + intent.ChannelId = request.ChannelId; + intent.UserId = request.RequestedByUserId; + db.LaunchCapabilities.Add(intent); + await db.SaveChangesAsync(ct); + return Results.Ok(new { intent.Id, intent.ExpiresAt }); + }); + + var device = app.MapGroup("/api/launch-intents").WithTags("Launch intents") + .RequireAuthorization("DeviceAuthenticated"); + device.MapPost("/redeem", async (TokenRequest request, ClaimsPrincipal user, AppDbContext db, + TimeProvider time, CancellationToken ct) => + { + var deviceIdentity = await DeviceIdentityEndpoints.RequireDeviceAsync(user, db, ct); + if (deviceIdentity is null) return Results.Unauthorized(); + if (request.Token is not { Length: 64 }) return Results.NotFound(); + var hash = RelayCapability.Hash(request.Token); + var intent = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.TokenHash == hash + && (x.Kind == "share" || x.Kind == "watch") && x.ExpiresAt > time.GetUtcNow() + && x.ConsumedAt == null, ct); + if (intent is null) return Results.NotFound(); + intent.ConsumedAt = time.GetUtcNow(); + intent.DeviceId = deviceIdentity.Id; + try { await db.SaveChangesAsync(ct); } + catch (DbUpdateConcurrencyException) { return Results.Conflict(); } + return Results.Ok(new { intent.Id, intent.Kind, intent.Code, intent.SessionId }); + }).RequireAuthorization("session:create"); + + device.MapPost("/{id:guid}/bind", async (Guid id, BindRequest request, ClaimsPrincipal user, + AppDbContext db, TimeProvider time, CancellationToken ct) => + { + var deviceIdentity = await DeviceIdentityEndpoints.RequireDeviceAsync(user, db, ct); + if (deviceIdentity is null) return Results.Unauthorized(); + var intent = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Id == id && x.Kind == "share" + && x.DeviceId == deviceIdentity.Id && x.ConsumedAt != null && x.ExpiresAt > time.GetUtcNow() + && x.SessionId == null, ct); + if (intent is null || !await db.StreamSessions.AnyAsync(x => x.Id == request.SessionId + && x.SourceDeviceId == deviceIdentity.Id && x.Mode == SessionModes.ScreenShare + && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct)) + return Results.NotFound(); + intent.SessionId = request.SessionId; + intent.ConsumedAt = time.GetUtcNow(); + try { await db.SaveChangesAsync(ct); } + catch (DbUpdateConcurrencyException) { return Results.Conflict(); } + return Results.NoContent(); + }).RequireAuthorization("session:create"); + + app.MapGet("/open/launch", (HttpContext context, IOptions options) => + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + var download = System.Text.Encodings.Web.HtmlEncoder.Default.Encode(options.Value.DownloadUrl); + const string start = "Open FrameRelay

Opening FrameRelay…

Open FrameRelay

Download or get help

"; + return Results.Content(start + download + end, "text/html"); + }).AllowAnonymous().WithTags("Launch links"); + + return app; + } + + private static bool ValidContext(params string[] values) => values.All(value => + value is { Length: > 0 and <= 32 } && value.All(char.IsAsciiDigit)); + + private static async Task ResolveSessionAsync(string code, AppDbContext db, + ISessionCodeStore codes, IConfiguration config, DateTimeOffset now, CancellationToken ct) + { + var normalized = (code ?? string.Empty).Trim().ToUpperInvariant(); + if (normalized.Length != 6 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c))) return null; + var key = config["Sessions:CodeHmacKey"]; + if (string.IsNullOrWhiteSpace(key)) return null; + var hash = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(key), + Encoding.UTF8.GetBytes(normalized))); + var id = await codes.RedeemAsync(hash, ct); + return id is null ? null : await db.StreamSessions.SingleOrDefaultAsync(x => x.Id == id + && x.Mode == SessionModes.ScreenShare && x.CodeExpiresAt > now + && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct); + } +} diff --git a/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs b/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs index d0fe89d..bafa255 100644 --- a/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/SessionEndpoints.cs @@ -274,13 +274,20 @@ private static async Task AdmitViewerAsync(StreamSession session, Devic // duplicate row eats a viewer slot and splits signaling routing across two participant // ids. The unique index on (SessionId, DeviceId, Role) is the cross-instance backstop; // this lock keeps the single-instance case off the constraint-violation path entirely. - using var admission = await admissionLock.AcquireAsync(session.Id, device.Id, ct); + // Activity and desktop admissions share the session gate so neither can consume the + // last viewer slot while the other is checking it. + using var admission = await admissionLock.AcquireAsync(session.Id, Guid.Empty, ct); + await using var transaction = session.Mode == SessionModes.ScreenShare && db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null; try { - return await AdmitViewerCoreAsync(session, device, db, loggerFactory, metrics, ct); + var result = await AdmitViewerCoreAsync(session, device, db, loggerFactory, metrics, ct); + if (transaction is not null) await transaction.CommitAsync(ct); + return result; } catch (DbUpdateException) { + if (transaction is not null) await transaction.RollbackAsync(ct); // Another API instance won the insert. Its row is the participant now; adopt it // rather than reporting a failure the client could only answer by retrying. db.ChangeTracker.Clear(); diff --git a/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs b/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs index d6b4944..c5df788 100644 --- a/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs +++ b/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs @@ -147,7 +147,11 @@ private static async Task HandleAsync(HttpContext context, AppDbContext db, ICon } } - using var socket = await context.WebSockets.AcceptWebSocketAsync(); + using var activityExpiry = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + var activity = context.User.HasClaim(x => x.Type == "activity_session"); + if (activity && long.TryParse(context.User.FindFirst("activity_expiry")?.Value, out var expiry)) + activityExpiry.CancelAfter(TimeSpan.FromSeconds(Math.Max(0, expiry - DateTimeOffset.UtcNow.ToUnixTimeSeconds()))); + using var socket = await context.WebSockets.AcceptWebSocketAsync(activity ? "framerelay" : null); using var socketSendLock = new SemaphoreSlim(1, 1); async Task SendFrameAsync(ReadOnlyMemory message, CancellationToken ct) { @@ -183,6 +187,14 @@ await registry.RegisterAsync(new ConnectionDescriptor( try { + async Task ActivityPresentAsync() + { + if (!Guid.TryParse(context.User.FindFirst("activity_capability")?.Value, out var id)) return false; + var capability = await db.LaunchCapabilities.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, activityExpiry.Token); + return capability is not null && await ActivityPresenceService.IsAuthorizedAsync(db, + context.RequestServices.GetRequiredService(), capability, + context.RequestServices.GetRequiredService().GetUtcNow(), activityExpiry.Token); + } // The join payload carries the participant's audio capabilities alongside the // long-standing participantId/role pair, so a client knows whether it may publish // audio (and what its peers may do) before any SDP is exchanged. Clients written @@ -194,14 +206,21 @@ await BroadcastAsync(registry, sessionId, participant.Id, peerAnnouncementType, CapabilitiesPayload(participant, session.Mode), context.RequestAborted); await SendPeerRosterAsync(SendFrameAsync, db, registry, session, participant.Id, context.RequestAborted); await ReceiveLoopAsync(socket, SendFrameAsync, session, participant.Id, db, registry, logger, metrics, - context.RequestAborted); + activityExpiry.Token, activity ? ActivityPresentAsync : null); } finally { metrics.ConnectionClosed(sessionId); await registry.UnregisterAsync(connectionId, CancellationToken.None); - await HandleDisconnectAsync(db, registry, reconnectTracker, scopeFactory, configuration, logger, - sessionId, participant.Id, connectionId); + if (activity) + { + await FinalizeDisconnectAsync(db, participant.Id, connectionId); + await BroadcastAsync(registry, sessionId, participant.Id, "session.left", participant.Id, + new { participantId = participant.Id }, CancellationToken.None); + } + else + await HandleDisconnectAsync(db, registry, reconnectTracker, scopeFactory, configuration, logger, + sessionId, participant.Id, connectionId); if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) { @@ -269,16 +288,32 @@ await BroadcastAsync(registry, sessionId, participantId, "session.left", partici private static async Task ReceiveLoopAsync(WebSocket socket, Func, CancellationToken, Task> sendAsync, StreamSession session, Guid participantId, AppDbContext db, IConnectionRegistry registry, ILogger logger, Observability.SonicRelayMetrics metrics, - CancellationToken ct) + CancellationToken ct, Func>? activityPresent = null) { var sessionId = session.Id; + var nextPresenceCheck = DateTimeOffset.UtcNow; while (socket.State == WebSocketState.Open && !ct.IsCancellationRequested) { + if (activityPresent is not null && DateTimeOffset.UtcNow >= nextPresenceCheck) + { + if (!await activityPresent()) return; + nextPresenceCheck = DateTimeOffset.UtcNow.AddSeconds(5); + } using var receiveCancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); var receiveTask = ReceiveMessageAsync(socket, receiveCancellation.Token); while (!receiveTask.IsCompleted) { var completed = await Task.WhenAny(receiveTask, Task.Delay(TimeSpan.FromSeconds(1), ct)); + if (activityPresent is not null && DateTimeOffset.UtcNow >= nextPresenceCheck) + { + if (!await activityPresent()) + { + await receiveCancellation.CancelAsync(); + try { await receiveTask; } catch (OperationCanceledException) { } + return; + } + nextPresenceCheck = DateTimeOffset.UtcNow.AddSeconds(5); + } if (completed != receiveTask && await SessionEndedAsync(db, sessionId, ct)) { await SendEnvelopeAsync(sendAsync, "session.ended", sessionId, null, participantId, null, ct); diff --git a/services/SonicRelay.Api/Program.cs b/services/SonicRelay.Api/Program.cs index 484d29e..8b562d7 100644 --- a/services/SonicRelay.Api/Program.cs +++ b/services/SonicRelay.Api/Program.cs @@ -117,6 +117,7 @@ })); builder.Services.Configure(builder.Configuration.GetSection("DeviceIdentity")); builder.Services.Configure(builder.Configuration.GetSection(LaunchIntentOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection("RelayLaunch")); builder.Services.AddCors(options => options.AddPolicy(SignalingGrantEndpoints.CorsPolicyName, policy => policy.WithOrigins([.. corsOptions.EffectiveAllowedOrigins]) .WithHeaders("Authorization", "Content-Type") @@ -126,6 +127,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService()); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10)); +builder.Services.AddHttpClient("DiscordActivityInstances", client => client.Timeout = TimeSpan.FromSeconds(10)); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -158,7 +163,8 @@ .AddScheme( SignalingGrantAuthenticationHandler.SchemeName, _ => { }) .AddScheme( - LaunchServiceAuthenticationHandler.SchemeName, _ => { }); + LaunchServiceAuthenticationHandler.SchemeName, _ => { }) + .AddScheme("Activity", _ => { }); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService()); @@ -224,7 +230,7 @@ options.AddPolicy("signaling:connect", policy => { - policy.AddAuthenticationSchemes("DeviceBearer", SignalingGrantAuthenticationHandler.SchemeName); + policy.AddAuthenticationSchemes("DeviceBearer", SignalingGrantAuthenticationHandler.SchemeName, "Activity"); policy.RequireAuthenticatedUser(); policy.Requirements.Add(new DeviceScopeRequirement("signaling:connect")); }); @@ -295,6 +301,8 @@ app.MapPairingEndpoints(); app.MapSessionEndpoints(); app.MapLaunchIntentEndpoints(); +app.MapDiscordActivityLaunchIntentEndpoints(); +app.MapDiscordActivityEndpoints(); app.MapWebRtcEndpoints(); app.MapSettingsEndpoints(); app.MapSignalingGrantEndpoints(); diff --git a/services/SonicRelay.Api/Services/ActivityPresenceService.cs b/services/SonicRelay.Api/Services/ActivityPresenceService.cs new file mode 100644 index 0000000..1978a19 --- /dev/null +++ b/services/SonicRelay.Api/Services/ActivityPresenceService.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Services; + +public static class ActivityPresenceService +{ + public static async Task IsAuthorizedAsync(AppDbContext db, IDiscordActivityValidator discord, + LaunchCapability capability, DateTimeOffset now, CancellationToken ct) + { + var authorized = await db.LaunchCapabilities.AnyAsync(x => + x.Kind == "activity" && x.InstanceId == capability.InstanceId && x.SessionId == capability.SessionId + && x.GuildId == capability.GuildId && x.ChannelId == capability.ChannelId && x.ExpiresAt > now, ct); + authorized = authorized && await db.StreamSessions.AnyAsync(x => x.Id == capability.SessionId + && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct); + var capabilities = await db.LaunchCapabilities.Where(x => x.Kind != "activity" && x.InstanceId == capability.InstanceId + && x.SessionId == capability.SessionId).ToListAsync(ct); + var absent = new HashSet(); + foreach (var user in capabilities.Where(x => x.ExpiresAt > now).DistinctBy(x => x.UserId)) + { + var present = authorized; + try + { + if (present) present = await discord.IsPresentAsync(new(user.UserId, user.InstanceId!, user.GuildId, user.ChannelId), ct); + } + catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or KeyNotFoundException + or InvalidOperationException or TaskCanceledException) { present = false; } + if (!present) absent.Add(user.UserId); + } + var revoked = capabilities.Where(x => absent.Contains(x.UserId) || x.ExpiresAt <= now).ToList(); + foreach (var cap in revoked) cap.ExpiresAt = now; + var participants = revoked.Where(x => x.ParticipantId != null).Select(x => x.ParticipantId!.Value).ToArray(); + foreach (var participant in await db.SessionParticipants.Where(x => participants.Contains(x.Id)).ToListAsync(ct)) + { + participant.Status = ParticipantStatuses.Disconnected; + participant.LeftAt = now; + } + if (revoked.Count > 0) await db.SaveChangesAsync(ct); + return capability.ExpiresAt > now && authorized && !absent.Contains(capability.UserId); + } +} diff --git a/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs b/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs new file mode 100644 index 0000000..6667b00 --- /dev/null +++ b/services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Services; + +public sealed class LaunchCapabilityCleanupService(IServiceScopeFactory scopes, TimeProvider time) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30), time); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = time.GetUtcNow(); + var expired = await db.LaunchCapabilities.Where(x => x.ExpiresAt <= now).ToListAsync(stoppingToken); + var ids = expired.Where(x => x.Kind == "signaling" && x.ParticipantId != null).Select(x => x.ParticipantId!.Value).ToArray(); + var participants = await db.SessionParticipants.Where(x => ids.Contains(x.Id)).ToListAsync(stoppingToken); + foreach (var participant in participants) + { + participant.Status = ParticipantStatuses.Disconnected; participant.LeftAt = now; + } + db.LaunchCapabilities.RemoveRange(expired); + await db.SaveChangesAsync(stoppingToken); + } + } +} diff --git a/services/SonicRelay.Api/Services/RelayLaunchService.cs b/services/SonicRelay.Api/Services/RelayLaunchService.cs new file mode 100644 index 0000000..dd507ea --- /dev/null +++ b/services/SonicRelay.Api/Services/RelayLaunchService.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; + +namespace SonicRelay.Api.Services; + +public sealed class RelayLaunchOptions +{ + public string? ServiceToken { get; set; } + public string PublicBaseUrl { get; set; } = ""; + public string DownloadUrl { get; set; } = "https://github.com"; + public string? DiscordClientId { get; set; } + public string? DiscordClientSecret { get; set; } + public string? DiscordBotToken { get; set; } + public string? DiscordRedirectUri { get; set; } +} + +public sealed record DiscordActivityIdentity(string UserId, string InstanceId, string GuildId, string ChannelId); +public interface IDiscordActivityValidator +{ + Task AuthorizeAsync(string code, string instanceId, CancellationToken ct); + Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct); +} + +public sealed class DiscordActivityValidator(HttpClient http, Microsoft.Extensions.Options.IOptions options, DiscordActivityInstanceCache instances) + : IDiscordActivityValidator +{ + public async Task AuthorizeAsync(string code, string instanceId, CancellationToken ct) + { + var settings = options.Value; + if (string.IsNullOrWhiteSpace(settings.DiscordClientId) || string.IsNullOrWhiteSpace(settings.DiscordClientSecret) + || string.IsNullOrWhiteSpace(settings.DiscordBotToken) || code is not { Length: > 0 and <= 2048 } || instanceId is not { Length: > 0 and <= 256 }) + return null; + var form = new Dictionary + { + ["client_id"] = settings.DiscordClientId, ["client_secret"] = settings.DiscordClientSecret, + ["grant_type"] = "authorization_code", ["code"] = code + }; + if (!string.IsNullOrEmpty(settings.DiscordRedirectUri)) form["redirect_uri"] = settings.DiscordRedirectUri; + using var response = await http.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(form), ct); + if (!response.IsSuccessStatusCode) return null; + using var token = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + if (!token.RootElement.TryGetProperty("access_token", out var access)) return null; + using var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/v10/users/@me"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access.GetString()); + using var userResponse = await http.SendAsync(request, ct); + if (!userResponse.IsSuccessStatusCode) return null; + using var user = JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(ct)); + var userId = user.RootElement.GetProperty("id").GetString()!; + return await GetInstanceAsync(userId, instanceId, ct); + } + + public async Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => + await GetInstanceAsync(identity.UserId, identity.InstanceId, ct) == identity; + + private async Task GetInstanceAsync(string userId, string instanceId, CancellationToken ct) + { + var snapshot = await instances.GetAsync(instanceId, ct); + return snapshot is not null && snapshot.Users.Contains(userId) + ? new(userId, instanceId, snapshot.GuildId, snapshot.ChannelId) : null; + } +} + +/// One coalesced REST request per instance per five seconds, including negative responses. +public sealed class DiscordActivityInstanceCache(IHttpClientFactory clients, + Microsoft.Extensions.Options.IOptions options, TimeProvider time) +{ + public sealed record Snapshot(string GuildId, string ChannelId, HashSet Users); + private sealed class Entry + { + public readonly SemaphoreSlim Gate = new(1); + public Snapshot? Value; + public DateTimeOffset Until; + } + private readonly System.Collections.Concurrent.ConcurrentDictionary _entries = new(); + public async Task GetAsync(string instanceId, CancellationToken ct) + { + if (instanceId is not { Length: > 0 and <= 256 }) return null; + foreach (var old in _entries.Where(x => x.Value.Until != default && x.Value.Gate.CurrentCount == 1 && x.Value.Until < time.GetUtcNow().AddMinutes(-1))) + _entries.TryRemove(old); + var entry = _entries.GetOrAdd(instanceId, _ => new Entry()); + await entry.Gate.WaitAsync(ct); + try + { + if (entry.Until > time.GetUtcNow()) return entry.Value; + entry.Value = null; + try + { + var settings = options.Value; + if (!string.IsNullOrWhiteSpace(settings.DiscordClientId) && !string.IsNullOrWhiteSpace(settings.DiscordBotToken)) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"https://discord.com/api/v10/applications/{Uri.EscapeDataString(settings.DiscordClientId)}/activity-instances/{Uri.EscapeDataString(instanceId)}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bot", settings.DiscordBotToken); + using var response = await clients.CreateClient("DiscordActivityInstances").SendAsync(request, ct); + if (response.IsSuccessStatusCode) + { + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var root = json.RootElement; + var location = root.GetProperty("location"); + if (root.GetProperty("application_id").GetString() == settings.DiscordClientId && root.GetProperty("instance_id").GetString() == instanceId && location.GetProperty("kind").GetString() == "gc") + entry.Value = new(location.GetProperty("guild_id").GetString()!, location.GetProperty("channel_id").GetString()!, + root.GetProperty("users").EnumerateArray().Select(x => x.GetString()!).ToHashSet()); + } + } + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or KeyNotFoundException or InvalidOperationException or TaskCanceledException) { entry.Value = null; } + entry.Until = time.GetUtcNow().AddSeconds(5); + return entry.Value; + } + finally { entry.Gate.Release(); } + } +} + +public static class RelayCapability +{ + public static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + public static LaunchCapability Create(string kind, string token, DateTimeOffset now, int ttl) => + new() { Kind = kind, TokenHash = Hash(token), CreatedAt = now, ExpiresAt = now.AddSeconds(Math.Clamp(ttl, 30, 900)) }; + public static async Task FindAsync(AppDbContext db, string token, string kind, DateTimeOffset now, CancellationToken ct) => + token is { Length: 64 } ? await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.TokenHash == Hash(token) + && x.Kind == kind && x.ExpiresAt > now && x.ConsumedAt == null, ct) : null; +} diff --git a/src/SonicRelay.Domain/Sessions/LaunchCapability.cs b/src/SonicRelay.Domain/Sessions/LaunchCapability.cs new file mode 100644 index 0000000..6a4e147 --- /dev/null +++ b/src/SonicRelay.Domain/Sessions/LaunchCapability.cs @@ -0,0 +1,21 @@ +namespace SonicRelay.Domain.Sessions; + +/// Only token hashes are stored. ConsumedAt is the optimistic concurrency guard. +public sealed class LaunchCapability +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Kind { get; set; } = "share"; + public string TokenHash { get; set; } = ""; + public string GuildId { get; set; } = ""; + public string ChannelId { get; set; } = ""; + public string UserId { get; set; } = ""; + public string? InstanceId { get; set; } + public Guid? SessionId { get; set; } + public Guid? DeviceId { get; set; } + public Guid? ParticipantId { get; set; } + public string? Code { get; set; } + public string? MessageId { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? ConsumedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs b/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs index c2e3e48..e8b783f 100644 --- a/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs @@ -10,6 +10,7 @@ namespace SonicRelay.Infrastructure.Persistence; public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet StreamSessions => Set(); + public DbSet LaunchCapabilities => Set(); public DbSet SessionParticipants => Set(); public DbSet SignalingEvents => Set(); public DbSet DeviceIdentities => Set(); @@ -22,6 +23,21 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + modelBuilder.Entity(entity => + { + entity.ToTable("launch_capabilities"); + entity.HasKey(x => x.Id); + entity.Property(x => x.ConsumedAt).IsConcurrencyToken(); + entity.HasIndex(x => x.TokenHash).IsUnique(); + entity.HasIndex(x => x.ExpiresAt); + // One binding per Discord instance across API processes; viewer credentials may + // share the instance, so only bootstrap rows participate in this unique index. + entity.HasIndex(x => x.InstanceId).IsUnique() + .HasFilter("\"Kind\" = 'activity' AND \"InstanceId\" IS NOT NULL"); + entity.Property(x => x.Kind).HasMaxLength(32); + entity.Property(x => x.TokenHash).HasMaxLength(64); + entity.Property(x => x.InstanceId).HasMaxLength(256); + }); modelBuilder.Entity(entity => { diff --git a/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.Designer.cs b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.Designer.cs new file mode 100644 index 0000000..ec7bb3b --- /dev/null +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.Designer.cs @@ -0,0 +1,418 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SonicRelay.Infrastructure.Persistence; + +#nullable disable + +namespace SonicRelay.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260926043350_AddLaunchCapabilities")] + partial class AddLaunchCapabilities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SonicRelay.Domain.DeviceIdentities.DeviceIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialSecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CredentialVersion") + .HasColumnType("integer"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_device_identities_created_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_device_identities_status"); + + b.ToTable("device_identities", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.DeviceIdentities.DevicePairing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PublisherDeviceId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ViewerDeviceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_device_pairings_created_at"); + + b.HasIndex("PublisherDeviceId") + .HasDatabaseName("ix_device_pairings_publisher_device_id"); + + b.HasIndex("ViewerDeviceId") + .HasDatabaseName("ix_device_pairings_viewer_device_id"); + + b.ToTable("device_pairings", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.DeviceIdentities.PairingChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("PublisherDeviceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_pairing_challenges_created_at"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_pairing_challenges_expires_at"); + + b.HasIndex("PublisherDeviceId") + .HasDatabaseName("ix_pairing_challenges_publisher_device_id"); + + b.ToTable("pairing_challenges", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.RelaySettings.RelayDeviceSettings", b => + { + b.Property("DeviceId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelayMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TurnCredential") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection("TurnUris") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("TurnUsername") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DeviceId"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_relay_device_settings_created_at"); + + b.ToTable("relay_device_settings", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Sessions.LaunchCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .HasColumnType("text"); + + b.Property("ConsumedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GuildId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstanceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MessageId") + .HasColumnType("text"); + + b.Property("ParticipantId") + .HasColumnType("uuid"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("launch_capabilities", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Sessions.SessionParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioMuted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("AudioSendAllowed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("CanReceiveAudio") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CanSendAudio") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ConnectionId") + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("JoinedAt") + .HasDatabaseName("ix_session_participants_joined_at"); + + b.HasIndex("SessionId", "Role") + .HasDatabaseName("ix_session_participants_session_role"); + + b.HasIndex("SessionId", "DeviceId", "Role") + .IsUnique() + .HasDatabaseName("ux_session_participants_session_device_role"); + + b.ToTable("session_participants", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Sessions.StreamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxViewers") + .HasColumnType("integer"); + + b.Property("Mode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("broadcast"); + + b.Property("SourceDeviceId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_stream_sessions_created_at"); + + b.HasIndex("SourceDeviceId", "Status") + .HasDatabaseName("ix_stream_sessions_source_device_status"); + + b.ToTable("stream_sessions", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Signaling.SignalingEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FromParticipantId") + .HasColumnType("uuid"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("ToParticipantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_signaling_events_created_at"); + + b.HasIndex("SessionId", "CreatedAt") + .HasDatabaseName("ix_signaling_events_session_created_at"); + + b.ToTable("signaling_events", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.cs b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.cs new file mode 100644 index 0000000..ee0ab29 --- /dev/null +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SonicRelay.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddLaunchCapabilities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "launch_capabilities", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Kind = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + GuildId = table.Column(type: "text", nullable: false), + ChannelId = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "text", nullable: false), + InstanceId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + SessionId = table.Column(type: "uuid", nullable: true), + DeviceId = table.Column(type: "uuid", nullable: true), + ParticipantId = table.Column(type: "uuid", nullable: true), + Code = table.Column(type: "text", nullable: true), + MessageId = table.Column(type: "text", nullable: true), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_launch_capabilities", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_launch_capabilities_ExpiresAt", + table: "launch_capabilities", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_launch_capabilities_TokenHash", + table: "launch_capabilities", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "launch_capabilities"); + } + } +} diff --git a/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.Designer.cs b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.Designer.cs new file mode 100644 index 0000000..7ad4de3 --- /dev/null +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.Designer.cs @@ -0,0 +1,422 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SonicRelay.Infrastructure.Persistence; + +#nullable disable + +namespace SonicRelay.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260926044953_EnforceActivityInstanceBinding")] + partial class EnforceActivityInstanceBinding + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SonicRelay.Domain.DeviceIdentities.DeviceIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialSecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CredentialVersion") + .HasColumnType("integer"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_device_identities_created_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_device_identities_status"); + + b.ToTable("device_identities", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.DeviceIdentities.DevicePairing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PublisherDeviceId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ViewerDeviceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_device_pairings_created_at"); + + b.HasIndex("PublisherDeviceId") + .HasDatabaseName("ix_device_pairings_publisher_device_id"); + + b.HasIndex("ViewerDeviceId") + .HasDatabaseName("ix_device_pairings_viewer_device_id"); + + b.ToTable("device_pairings", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.DeviceIdentities.PairingChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("PublisherDeviceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_pairing_challenges_created_at"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_pairing_challenges_expires_at"); + + b.HasIndex("PublisherDeviceId") + .HasDatabaseName("ix_pairing_challenges_publisher_device_id"); + + b.ToTable("pairing_challenges", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.RelaySettings.RelayDeviceSettings", b => + { + b.Property("DeviceId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelayMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TurnCredential") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection("TurnUris") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("TurnUsername") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DeviceId"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_relay_device_settings_created_at"); + + b.ToTable("relay_device_settings", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Sessions.LaunchCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .HasColumnType("text"); + + b.Property("ConsumedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GuildId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstanceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MessageId") + .HasColumnType("text"); + + b.Property("ParticipantId") + .HasColumnType("uuid"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("InstanceId") + .IsUnique() + .HasFilter("\"Kind\" = 'activity' AND \"InstanceId\" IS NOT NULL"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("launch_capabilities", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Sessions.SessionParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioMuted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("AudioSendAllowed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("CanReceiveAudio") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CanSendAudio") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ConnectionId") + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("JoinedAt") + .HasDatabaseName("ix_session_participants_joined_at"); + + b.HasIndex("SessionId", "Role") + .HasDatabaseName("ix_session_participants_session_role"); + + b.HasIndex("SessionId", "DeviceId", "Role") + .IsUnique() + .HasDatabaseName("ux_session_participants_session_device_role"); + + b.ToTable("session_participants", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Sessions.StreamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxViewers") + .HasColumnType("integer"); + + b.Property("Mode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("broadcast"); + + b.Property("SourceDeviceId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_stream_sessions_created_at"); + + b.HasIndex("SourceDeviceId", "Status") + .HasDatabaseName("ix_stream_sessions_source_device_status"); + + b.ToTable("stream_sessions", (string)null); + }); + + modelBuilder.Entity("SonicRelay.Domain.Signaling.SignalingEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FromParticipantId") + .HasColumnType("uuid"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("ToParticipantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_signaling_events_created_at"); + + b.HasIndex("SessionId", "CreatedAt") + .HasDatabaseName("ix_signaling_events_session_created_at"); + + b.ToTable("signaling_events", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.cs b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.cs new file mode 100644 index 0000000..7c47661 --- /dev/null +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SonicRelay.Infrastructure.Persistence.Migrations +{ + /// + public partial class EnforceActivityInstanceBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_launch_capabilities_InstanceId", + table: "launch_capabilities", + column: "InstanceId", + unique: true, + filter: "\"Kind\" = 'activity' AND \"InstanceId\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_launch_capabilities_InstanceId", + table: "launch_capabilities"); + } + } +} diff --git a/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index bc313d9..2240e87 100644 --- a/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -307,6 +307,77 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("relay_device_settings", (string)null); }); + modelBuilder.Entity("SonicRelay.Domain.Sessions.LaunchCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .HasColumnType("text"); + + b.Property("ConsumedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GuildId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstanceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MessageId") + .HasColumnType("text"); + + b.Property("ParticipantId") + .HasColumnType("uuid"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("InstanceId") + .IsUnique() + .HasFilter("\"Kind\" = 'activity' AND \"InstanceId\" IS NOT NULL"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("launch_capabilities", (string)null); + }); + modelBuilder.Entity("SonicRelay.Domain.Sessions.SessionParticipant", b => { b.Property("Id") diff --git a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs new file mode 100644 index 0000000..399b8ac --- /dev/null +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -0,0 +1,209 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using SonicRelay.Api.Services; +using SonicRelay.Domain.Devices; +using SonicRelay.Domain.Sessions; +using SonicRelay.Infrastructure.Persistence; +using Xunit; + +namespace SonicRelay.Api.IntegrationTests; + +public sealed class LaunchIntentActivityTests +{ + private sealed class InstanceHttp : HttpMessageHandler, IHttpClientFactory + { + public int Calls; + public HttpClient CreateClient(string name) => new(this, disposeHandler: false); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Interlocked.Increment(ref Calls); + Assert.Equal("Bot", request.Headers.Authorization!.Scheme); + Assert.EndsWith("/applications/app/activity-instances/instance", request.RequestUri!.AbsoluteUri); + await Task.Delay(20, ct); + return new(HttpStatusCode.OK) { Content = JsonContent.Create(new { application_id = "app", instance_id = "instance", users = new[] { "3", "4" }, location = new { kind = "gc", guild_id = "1", channel_id = "2" } }) }; + } + } + private sealed class Discord : IDiscordActivityValidator + { + public volatile bool Present = true; + public Task AuthorizeAsync(string code, string instanceId, CancellationToken ct) => + Task.FromResult(code == "valid" ? new("3", instanceId, "1", "2") : code == "other" ? new("4", instanceId, "1", "2") : null); + public Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => Task.FromResult(Present); + } + private static SonicRelayApiFactory Factory() => new(new Dictionary + { ["LaunchIntents:ServiceToken"] = "bot-secret", ["RelayLaunch:PublicBaseUrl"] = "https://relay.example" }); + private static HttpClient Bot(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory factory) + { + var bot = factory.CreateClient(); bot.DefaultRequestHeaders.Authorization = new("Bearer", "bot-secret"); return bot; + } + private static async Task Json(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync()).Clone(); + } + private static async Task Screen(HttpClient host) => await Json(await host.PostAsJsonAsync("/api/sessions/", new { mode = "screen_share", maxViewers = 1 })); + + [Fact] + public async Task Discord_instance_poll_is_coalesced_for_all_viewers_and_refreshes_after_five_seconds() + { + var transport = new InstanceHttp(); var clock = new TestTimeProvider(); + var cache = new DiscordActivityInstanceCache(transport, Options.Create(new RelayLaunchOptions { DiscordClientId = "app", DiscordBotToken = "secret" }), clock); + var results = await Task.WhenAll(Enumerable.Range(0, 12).Select(_ => cache.GetAsync("instance", CancellationToken.None))); + Assert.Equal(1, transport.Calls); Assert.All(results, x => Assert.Contains("4", x!.Users)); + clock.Advance(TimeSpan.FromSeconds(6)); await cache.GetAsync("instance", CancellationToken.None); + Assert.Equal(2, transport.Calls); + } + + [Fact] + public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_owner_bound() + { + using var factory = Factory(); using var bot = Bot(factory); + Assert.Equal(HttpStatusCode.Unauthorized, (await factory.CreateClient().PostAsJsonAsync("/api/launch-intents/share", new { provider = "discord", guildId = "1", channelId = "2", requestedByUserId = "3" })).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await bot.PostAsJsonAsync("/api/sessions/", new { })).StatusCode); + var intent = await Json(await bot.PostAsJsonAsync("/api/launch-intents/share", new { provider = "discord", guildId = "1", channelId = "2", requestedByUserId = "3" })); + var pending = await Json(await bot.GetAsync("/api/launch-intents/pending")); + Assert.Equal(JsonValueKind.Array, pending.ValueKind); + Assert.Equal(intent.GetProperty("id").GetGuid(), pending[0].GetProperty("id").GetGuid()); + Assert.Equal("pending", pending[0].GetProperty("status").GetString()); + var launchUrl = intent.GetProperty("launchUrl").GetString()!; + var token = launchUrl[(launchUrl.LastIndexOf('/') + 1)..]; + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + Assert.Equal(HttpStatusCode.OK, (await host.PostAsJsonAsync("/api/launch-intents/share/consume", new { token })).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await host.PostAsJsonAsync("/api/launch-intents/share/consume", new { token })).StatusCode); + var session = await Screen(host); + var id = intent.GetProperty("id").GetGuid(); + Assert.Equal(HttpStatusCode.NoContent, (await host.PostAsJsonAsync($"/api/launch-intents/share/{id}/complete", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); + var polling = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=0")); + Assert.Equal("session_ready", polling.GetProperty("status").GetString()); + Assert.Equal(session.GetProperty("id").GetGuid(), polling.GetProperty("sessionId").GetGuid()); + Assert.Equal(JsonValueKind.String, polling.GetProperty("watchLaunchUrl").ValueKind); + var ready = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=60")); + Assert.Equal("session_ready", ready.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.String, ready.GetProperty("watchLaunchUrl").ValueKind); + var defaultWatch = await Json(await bot.GetAsync($"/api/launch-intents/{id}")); + Assert.Equal(JsonValueKind.String, defaultWatch.GetProperty("watchLaunchUrl").ValueKind); + var readyItems = await Json(await bot.GetAsync("/api/launch-intents/pending")); + Assert.Equal("session_ready", readyItems[0].GetProperty("status").GetString()); + } + + [Fact] + public async Task Activity_requires_invoker_bootstrap_and_enforces_single_use_grants_and_viewer_limit() + { + using var root = Factory(); using var factory = root.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { services.RemoveAll(); services.AddSingleton(); })); + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + var screen = await Screen(host); using var bot = Bot(factory); using var viewer = factory.CreateClient(); + await Json(await bot.PostAsJsonAsync("/api/launch-intents/activity", new { code = screen.GetProperty("code").GetString(), guildId = "1", channelId = "2", requestedByUserId = "3" })); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "other", instanceId = "instance" })).StatusCode); + var identity = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "other-instance" })).StatusCode); + await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "other", instanceId = "instance" })); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", identity.GetProperty("accessToken").GetString()); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/sessions/", new { })).StatusCode); + var grant = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + var admitted = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })); + Assert.Equal(screen.GetProperty("id").GetGuid(), admitted.GetProperty("sessionId").GetGuid()); + Assert.False(admitted.TryGetProperty("accessToken", out _)); Assert.True(admitted.TryGetProperty("signalingToken", out _)); + using var signal = factory.CreateClient(); + signal.DefaultRequestHeaders.Add("Sec-WebSocket-Protocol", "framerelay,token." + admitted.GetProperty("signalingToken").GetString()); + Assert.Equal(HttpStatusCode.Unauthorized, (await signal.GetAsync("/ws/signaling?sessionId=" + Guid.NewGuid())).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await signal.GetAsync("/ws/signaling?sessionId=" + admitted.GetProperty("sessionId").GetGuid())).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await signal.GetAsync("/ws/signaling?sessionId=" + admitted.GetProperty("sessionId").GetGuid())).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })).StatusCode); + var another = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + Assert.Equal(HttpStatusCode.Conflict, (await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = another.GetProperty("grant").GetString() })).StatusCode); + await host.PostAsJsonAsync("/api/sessions/" + screen.GetProperty("id").GetGuid() + "/end", new { }); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsync("/api/discord/activity/viewer-grants", null)).StatusCode); + } + + [Fact] + public async Task Expired_launch_capability_cannot_be_redeemed() + { + var clock = new TestTimeProvider(); + using var factory = new SonicRelayApiFactory(new Dictionary { ["LaunchIntents:ServiceToken"] = "bot-secret" }) { TimeProviderOverride = clock }; + using var bot = Bot(factory); + var intent = await Json(await bot.PostAsJsonAsync("/api/launch-intents/share", new { provider = "discord", guildId = "1", channelId = "2", requestedByUserId = "3", ttlSeconds = 30 })); + var launchUrl = intent.GetProperty("launchUrl").GetString()!; + var token = launchUrl[(launchUrl.LastIndexOf('/') + 1)..]; + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsPublisher, DevicePlatforms.Windows); + clock.Advance(TimeSpan.FromSeconds(31)); + Assert.Equal(HttpStatusCode.Conflict, (await host.PostAsJsonAsync("/api/launch-intents/share/consume", new { token })).StatusCode); + } + + [Fact] + public async Task Concurrent_first_binds_keep_one_session_and_database_has_unique_instance_backstop() + { + using var root = Factory(); using var factory = root.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { services.RemoveAll(); services.AddSingleton(); })); + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + var first = await Screen(host); var second = await Screen(host); + using var bot = Bot(factory); using var viewer = factory.CreateClient(); + foreach (var item in new[] { (Screen: first, User: "3"), (Screen: second, User: "4") }) + await Json(await bot.PostAsJsonAsync("/api/launch-intents/activity", new { code = item.Screen.GetProperty("code").GetString(), guildId = "1", channelId = "2", requestedByUserId = item.User })); + var responses = await Task.WhenAll(viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" }), + viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "other", instanceId = "instance" })); + Assert.Single(responses, x => x.StatusCode == HttpStatusCode.OK); + Assert.Single(responses, x => x.StatusCode == HttpStatusCode.Conflict); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await db.LaunchCapabilities.CountAsync(x => x.Kind == "activity" && x.InstanceId == "instance")); + Assert.Contains(db.Model.FindEntityType(typeof(LaunchCapability))!.GetIndexes(), x => x.IsUnique && x.Properties.Single().Name == "InstanceId" && x.GetFilter()!.Contains("activity")); + } + + [Fact] + public async Task New_watch_in_busy_instance_conflicts_and_can_bind_after_previous_session_ends() + { + using var root = Factory(); using var factory = root.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { services.RemoveAll(); services.AddSingleton(); })); + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + var first = await Screen(host); var second = await Screen(host); + using var bot = Bot(factory); using var viewer = factory.CreateClient(); + await Json(await bot.PostAsJsonAsync("/api/launch-intents/activity", new { code = first.GetProperty("code").GetString(), guildId = "1", channelId = "2", requestedByUserId = "3" })); + var old = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })); + await Json(await bot.PostAsJsonAsync("/api/launch-intents/activity", new { code = second.GetProperty("code").GetString(), guildId = "1", channelId = "2", requestedByUserId = "3" })); + Assert.Equal(HttpStatusCode.Conflict, (await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })).StatusCode); + await host.PostAsJsonAsync("/api/sessions/" + first.GetProperty("id").GetGuid() + "/end", new { }); + var fresh = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", old.GetProperty("accessToken").GetString()); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsync("/api/discord/activity/viewer-grants", null)).StatusCode); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", fresh.GetProperty("accessToken").GetString()); + var grant = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + var admitted = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })); + Assert.Equal(second.GetProperty("id").GetGuid(), admitted.GetProperty("sessionId").GetGuid()); + } + + [Fact] + public async Task Observed_discord_departure_closes_socket_and_revokes_outstanding_credentials() + { + var discord = new Discord(); + using var root = Factory(); using var factory = root.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { services.RemoveAll(); services.AddSingleton(discord); })); + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + var screen = await Screen(host); using var bot = Bot(factory); using var viewer = factory.CreateClient(); + await Json(await bot.PostAsJsonAsync("/api/launch-intents/activity", new { code = screen.GetProperty("code").GetString(), guildId = "1", channelId = "2", requestedByUserId = "3" })); + var identity = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", identity.GetProperty("accessToken").GetString()); + var grant = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + var admitted = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })); + var outstanding = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + var client = factory.Server.CreateWebSocketClient(); + client.ConfigureRequest = request => request.Headers["Sec-WebSocket-Protocol"] = "framerelay,token." + admitted.GetProperty("signalingToken").GetString(); + using var socket = await client.ConnectAsync(new Uri("ws://localhost/ws/signaling?sessionId=" + admitted.GetProperty("sessionId").GetGuid()), CancellationToken.None); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(12)); + var buffer = new byte[65536]; + await socket.ReceiveAsync(new ArraySegment(buffer), deadline.Token); + discord.Present = false; + WebSocketReceiveResult frame; + do { frame = await socket.ReceiveAsync(new ArraySegment(buffer), deadline.Token); } while (frame.MessageType != WebSocketMessageType.Close); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = outstanding.GetProperty("grant").GetString() })).StatusCode); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(ParticipantStatuses.Disconnected, (await db.SessionParticipants.FindAsync(admitted.GetProperty("participantId").GetGuid()))!.Status); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "done", deadline.Token); + } +}