From 1ecc7677e36937e0dc37b122e716361ab12a3811 Mon Sep 17 00:00:00 2001 From: vitorhugo-dotnet Date: Sat, 26 Sep 2026 01:40:56 -0300 Subject: [PATCH 1/5] Add restricted launch intents and Discord Activity viewer authorization --- docs/discord-activity-launch-contract.md | 63 +++ .../ActivityAuthenticationHandler.cs | 30 ++ .../DeviceScopeAuthorizationHandler.cs | 5 + .../Endpoints/DiscordActivityEndpoints.cs | 92 ++++ .../Endpoints/LaunchIntentEndpoints.cs | 139 ++++++ .../Endpoints/SessionEndpoints.cs | 11 +- .../Endpoints/SignalingWebSocketEndpoint.cs | 19 +- services/SonicRelay.Api/Program.cs | 10 +- .../LaunchCapabilityCleanupService.cs | 28 ++ .../Services/RelayLaunchService.cs | 88 ++++ .../Sessions/LaunchCapability.cs | 21 + .../Persistence/AppDbContext.cs | 12 + ...26043350_AddLaunchCapabilities.Designer.cs | 418 ++++++++++++++++++ .../20260926043350_AddLaunchCapabilities.cs | 58 +++ .../Migrations/AppDbContextModelSnapshot.cs | 67 +++ .../LaunchIntentActivityTests.cs | 96 ++++ 16 files changed, 1150 insertions(+), 7 deletions(-) create mode 100644 docs/discord-activity-launch-contract.md create mode 100644 services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs create mode 100644 services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs create mode 100644 services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs create mode 100644 services/SonicRelay.Api/Services/LaunchCapabilityCleanupService.cs create mode 100644 services/SonicRelay.Api/Services/RelayLaunchService.cs create mode 100644 src/SonicRelay.Domain/Sessions/LaunchCapability.cs create mode 100644 src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.Designer.cs create mode 100644 src/SonicRelay.Infrastructure/Persistence/Migrations/20260926043350_AddLaunchCapabilities.cs create mode 100644 tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs diff --git a/docs/discord-activity-launch-contract.md b/docs/discord-activity-launch-contract.md new file mode 100644 index 0000000..aa71ea5 --- /dev/null +++ b/docs/discord-activity-launch-contract.md @@ -0,0 +1,63 @@ +# 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` 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 | `{items:[{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 `ready`. 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. + +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. +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. +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..d7fc6c4 --- /dev/null +++ b/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs @@ -0,0 +1,30 @@ +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) + : 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."); + 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()) }; + 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..33f76e7 --- /dev/null +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs @@ -0,0 +1,92 @@ +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, 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(); + // An existing instance accepts separately authenticated current participants. Only the + // original invoking user can bind a fresh bootstrap to an instance. + var intent = await db.LaunchCapabilities.FirstOrDefaultAsync(x => x.Kind == "activity" && x.InstanceId == identity.InstanceId && x.GuildId == identity.GuildId && x.ChannelId == identity.ChannelId && x.ExpiresAt > now, ct); + if (intent is null) + { + intent = 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); + if (intent is null) return Results.Unauthorized(); + intent.ConsumedAt = now; intent.InstanceId = identity.InstanceId; intent.ExpiresAt = now.AddMinutes(15); + } + 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); } catch (DbUpdateConcurrencyException) { return Results.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 LiveAsync(db, identity.SessionId, ct)) return Results.Unauthorized(); + if (!await PresentAsync(discord, new(identity.UserId, identity.InstanceId!, identity.GuildId, identity.ChannelId), 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); + await using var transaction = db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null; + if (!await PresentAsync(discord, new(grant.UserId, grant.InstanceId!, grant.GuildId, grant.ChannelId), ct)) return Results.Unauthorized(); + 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 async Task PresentAsync(IDiscordActivityValidator discord, DiscordActivityIdentity identity, CancellationToken ct) + { + try { return await discord.IsPresentAsync(identity, ct); } + catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException or TaskCanceledException) { return false; } + } + 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/LaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs new file mode 100644 index 0000000..d5857a5 --- /dev/null +++ b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs @@ -0,0 +1,139 @@ +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; + +public static class LaunchIntentEndpoints +{ + public sealed record ShareRequest(string Provider, string GuildId, string ChannelId, string RequestedByUserId, int TtlSeconds = 300); + public sealed record WatchRequest(string Code, int TtlSeconds = 300); + 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 sealed record PublishedRequest(string? MessageId); + + public static IEndpointRouteBuilder MapLaunchIntentEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/launch-intents"); + group.AddEndpointFilter(async (context, next) => + { + // Device redemption/binding use the normal device policies. Every other route is bot-only. + if (context.HttpContext.GetEndpoint()?.Metadata.GetMetadata() is not null) return await next(context); + var expected = context.HttpContext.RequestServices.GetRequiredService>().Value.ServiceToken; + var actual = context.HttpContext.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(expected) || !CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Encoding.UTF8.GetBytes(actual)), SHA256.HashData(Encoding.UTF8.GetBytes("Bearer " + expected)))) + return Results.Unauthorized(); + return await next(context); + }); + group.MapPost("/share", async (ShareRequest request, AppDbContext db, IOptions options, TimeProvider time, CancellationToken ct) => + { + if (request.Provider != "discord" || !ValidContext(request.GuildId, request.ChannelId, request.RequestedByUserId)) return Results.BadRequest(); + var token = RelayCapability.NewToken(); + var intent = RelayCapability.Create("share", token, time.GetUtcNow(), request.TtlSeconds); + 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, launchUrl = Url(options.Value, token), intent.ExpiresAt }); + }); + group.MapPost("/watch", async (WatchRequest request, AppDbContext db, ISessionCodeStore codes, IConfiguration config, + IOptions options, TimeProvider time, CancellationToken ct) => + { + 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("watch", token, time.GetUtcNow(), request.TtlSeconds); + intent.SessionId = session.Id; intent.Code = request.Code.Trim().ToUpperInvariant(); + db.LaunchCapabilities.Add(intent); await db.SaveChangesAsync(ct); + return Results.Ok(new { launchUrl = Url(options.Value, token), intent.ExpiresAt }); + }); + group.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 intent = RelayCapability.Create("activity", RelayCapability.NewToken(), 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 }); + }); + group.MapGet("/pending", async (AppDbContext db, TimeProvider time, CancellationToken ct) => Results.Ok(new + { + items = await db.LaunchCapabilities.Where(x => x.Kind == "share" && x.ExpiresAt > time.GetUtcNow() && x.MessageId == null) + .Select(x => new { x.Id, x.GuildId, x.ChannelId, requestedByUserId = x.UserId, status = x.SessionId == null ? "pending" : "ready", x.ExpiresAt }).ToListAsync(ct) + })); + group.MapGet("/{id:guid}", async (Guid id, int? watchTtlSeconds, AppDbContext db, IOptions options, TimeProvider time, CancellationToken ct) => + { + var intent = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Id == id && x.Kind == "share" && x.ExpiresAt > time.GetUtcNow(), ct); + if (intent is null) return Results.NotFound(); + string? watchLaunchUrl = null; + if (intent.SessionId is { } sessionId) + { + var session = await db.StreamSessions.FindAsync([sessionId], ct); + if (session is null || session.Status is SessionStatuses.Ended or SessionStatuses.Expired) return Results.NotFound(); + var token = RelayCapability.NewToken(); + var watch = RelayCapability.Create("watch", token, time.GetUtcNow(), watchTtlSeconds ?? 300); + watch.SessionId = sessionId; + db.LaunchCapabilities.Add(watch); await db.SaveChangesAsync(ct); + watchLaunchUrl = Url(options.Value, token); + } + return Results.Ok(new { intent.Id, status = intent.SessionId == null ? "pending" : "ready", intent.SessionId, watchLaunchUrl, intent.ExpiresAt }); + }); + group.MapPost("/{id:guid}/published", async (Guid id, PublishedRequest request, AppDbContext db, TimeProvider time, CancellationToken ct) => + { + var intent = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Id == id && x.Kind == "share" && x.ExpiresAt > time.GetUtcNow(), ct); + if (intent?.SessionId is null) return Results.NotFound(); + intent.MessageId = request.MessageId ?? "published"; await db.SaveChangesAsync(ct); return Results.NoContent(); + }); + group.MapPost("/redeem", RedeemAsync).WithMetadata(new DeviceLaunchOperation()).RequireAuthorization("DeviceAuthenticated"); + group.MapPost("/{id:guid}/bind", async (Guid id, BindRequest request, ClaimsPrincipal user, AppDbContext db, TimeProvider time, CancellationToken ct) => + { + var device = await DeviceIdentityEndpoints.RequireDeviceAsync(user, db, ct); + if (device is null) return Results.Unauthorized(); + var intent = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Id == id && x.Kind == "share" && x.DeviceId == device.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 == device.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(); + }).WithMetadata(new DeviceLaunchOperation()).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); + return Results.Content("Open FrameRelay

Opening FrameRelay…

Open FrameRelay

Download or get help

", "text/html"); + }); + return app; + } + + private sealed class DeviceLaunchOperation; + private static bool ValidContext(params string[] values) => values.All(x => x is { Length: > 0 and <= 32 } && x.All(char.IsAsciiDigit)); + private static string Url(RelayLaunchOptions settings, string token) => settings.PublicBaseUrl.TrimEnd('/') + "/open/launch#" + token; + private static async Task RedeemAsync(TokenRequest request, ClaimsPrincipal user, AppDbContext db, TimeProvider time, CancellationToken ct) + { + var device = await DeviceIdentityEndpoints.RequireDeviceAsync(user, db, ct); + if (device 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 = device.Id; + try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { return Results.Conflict(); } + return Results.Ok(new { intent.Id, intent.Kind, intent.Code, intent.SessionId }); + } + internal static async Task ResolveSessionAsync(string code, AppDbContext db, ISessionCodeStore codes, IConfiguration config, DateTimeOffset now, CancellationToken ct) + { + var normalized = code?.Trim().ToUpperInvariant() ?? ""; + if (normalized.Length != 6 || normalized.Any(x => !char.IsAsciiLetterOrDigit(x))) return null; + var hash = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(config["Sessions:CodeHmacKey"]!), 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 8f28fc3..7616143 100644 --- a/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs +++ b/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs @@ -93,7 +93,11 @@ private static async Task HandleAsync(HttpContext context, AppDbContext db, ICon return; } - 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) { @@ -140,14 +144,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); } 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) { diff --git a/services/SonicRelay.Api/Program.cs b/services/SonicRelay.Api/Program.cs index d47121f..98b560f 100644 --- a/services/SonicRelay.Api/Program.cs +++ b/services/SonicRelay.Api/Program.cs @@ -87,8 +87,13 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection("RelayLaunch")); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10)); -builder.Services.AddAuthentication().AddJwtBearer("DeviceBearer", jwtOptions => +builder.Services.AddAuthentication("DeviceBearer") + .AddScheme("Activity", _ => { }) + .AddJwtBearer("DeviceBearer", jwtOptions => { // Keep claim types as issued (e.g. "sub", not ClaimTypes.NameIdentifier) so // downstream code reading JwtRegisteredClaimNames.Sub/"cv"/"scope" matches @@ -181,6 +186,7 @@ options.AddPolicy(scope, policy => { policy.AddAuthenticationSchemes("DeviceBearer"); + if (scope == "signaling:connect") policy.AddAuthenticationSchemes("Activity"); policy.RequireAuthenticatedUser(); policy.Requirements.Add(new DeviceScopeRequirement(scope)); }); @@ -231,6 +237,8 @@ app.MapSettingsEndpoints(); app.MapSignalingWebSocketEndpoint(); app.MapPublicRoomEndpoints(); +app.MapLaunchIntentEndpoints(); +app.MapDiscordActivityEndpoints(); app.Run(); 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..5b3e0e3 --- /dev/null +++ b/services/SonicRelay.Api/Services/RelayLaunchService.cs @@ -0,0 +1,88 @@ +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) + : 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 settings = options.Value; + 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 http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) return null; + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var root = json.RootElement; + if (root.GetProperty("application_id").GetString() != settings.DiscordClientId + || root.GetProperty("instance_id").GetString() != instanceId + || !root.GetProperty("users").EnumerateArray().Any(x => x.GetString() == userId)) return null; + var location = root.GetProperty("location"); + if (location.GetProperty("kind").GetString() != "gc") return null; + return new(userId, instanceId, location.GetProperty("guild_id").GetString()!, location.GetProperty("channel_id").GetString()!); + } +} + +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 9fa39df..5c8b9b6 100644 --- a/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs @@ -9,6 +9,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(); @@ -19,6 +20,17 @@ 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); + 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/AppDbContextModelSnapshot.cs b/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index fec4d7e..69d99ff 100644 --- a/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -196,6 +196,73 @@ 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("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..2fe6891 --- /dev/null +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SonicRelay.Api.Services; +using SonicRelay.Domain.Devices; +using Xunit; + +namespace SonicRelay.Api.IntegrationTests; + +public sealed class LaunchIntentActivityTests +{ + private sealed class Discord : IDiscordActivityValidator + { + 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(true); + } + private static SonicRelayApiFactory Factory() => new(new Dictionary + { ["RelayLaunch: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 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 token = new Uri(intent.GetProperty("launchUrl").GetString()!).Fragment[1..]; + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + var redeemed = await Json(await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })); + Assert.Equal("share", redeemed.GetProperty("kind").GetString()); + Assert.Equal(HttpStatusCode.NotFound, (await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })).StatusCode); + var session = await Screen(host); + using var other = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(other, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + var id = intent.GetProperty("id").GetGuid(); + Assert.Equal(HttpStatusCode.NotFound, (await other.PostAsJsonAsync($"/api/launch-intents/{id}/bind", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.PostAsJsonAsync($"/api/launch-intents/{id}/bind", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); + var ready = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=60")); Assert.Equal("ready", ready.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 { ["RelayLaunch: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 token = intent.GetProperty("launchUrl").GetString()!.Split('#')[1]; + using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + clock.Advance(TimeSpan.FromSeconds(31)); + Assert.Equal(HttpStatusCode.NotFound, (await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })).StatusCode); + } +} From d517fbac30f85d9e057f2b48785fbbbd0b8b7b25 Mon Sep 17 00:00:00 2001 From: vitorhugo-dotnet Date: Sat, 26 Sep 2026 01:54:15 -0300 Subject: [PATCH 2/5] Enforce unique Activity binding and revoke departed viewers --- docs/discord-activity-launch-contract.md | 14 +- .../ActivityAuthenticationHandler.cs | 5 +- .../Endpoints/DiscordActivityEndpoints.cs | 55 ++- .../Endpoints/LaunchIntentEndpoints.cs | 7 +- .../Endpoints/SignalingWebSocketEndpoint.cs | 28 +- services/SonicRelay.Api/Program.cs | 2 + .../Services/ActivityPresenceService.cs | 42 ++ .../Services/RelayLaunchService.cs | 70 ++- .../Persistence/AppDbContext.cs | 4 + ...EnforceActivityInstanceBinding.Designer.cs | 422 ++++++++++++++++++ ...26044953_EnforceActivityInstanceBinding.cs | 29 ++ .../Migrations/AppDbContextModelSnapshot.cs | 4 + .../LaunchIntentActivityTests.cs | 105 ++++- 13 files changed, 743 insertions(+), 44 deletions(-) create mode 100644 services/SonicRelay.Api/Services/ActivityPresenceService.cs create mode 100644 src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.Designer.cs create mode 100644 src/SonicRelay.Infrastructure/Persistence/Migrations/20260926044953_EnforceActivityInstanceBinding.cs diff --git a/docs/discord-activity-launch-contract.md b/docs/discord-activity-launch-contract.md index aa71ea5..95f2b48 100644 --- a/docs/discord-activity-launch-contract.md +++ b/docs/discord-activity-launch-contract.md @@ -3,7 +3,7 @@ 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` before enabling bot routes. Existing device session routes remain +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 `: @@ -11,7 +11,7 @@ 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 | `{items:[{id,guildId,channelId,requestedByUserId,status,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}` | @@ -40,6 +40,11 @@ The Activity SDK authorizes with `identify`. It submits its authorization code a 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}` → @@ -51,7 +56,10 @@ The Activity SDK authorizes with `identify`. It submits its authorization code a 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. -Disconnected Activity viewers do not use the desktop reconnect grace period. Obtain a new grant +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 diff --git a/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs b/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs index d7fc6c4..248b23b 100644 --- a/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs +++ b/services/SonicRelay.Api/Authorization/ActivityAuthenticationHandler.cs @@ -10,7 +10,7 @@ namespace SonicRelay.Api.Authorization; public sealed class ActivityAuthenticationHandler(IOptionsMonitor options, - ILoggerFactory logger, UrlEncoder encoder, AppDbContext db, TimeProvider time) + ILoggerFactory logger, UrlEncoder encoder, AppDbContext db, TimeProvider time, IDiscordActivityValidator discord) : AuthenticationHandler(options, logger, encoder) { protected override async Task HandleAuthenticateAsync() @@ -22,9 +22,10 @@ protected override async Task HandleAuthenticateAsync() 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()) }; + 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/Endpoints/DiscordActivityEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs index 33f76e7..39ff0f1 100644 --- a/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs @@ -14,36 +14,61 @@ 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, TimeProvider time, CancellationToken ct) => + 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(); - // An existing instance accepts separately authenticated current participants. Only the - // original invoking user can bind a fresh bootstrap to an instance. - var intent = await db.LaunchCapabilities.FirstOrDefaultAsync(x => x.Kind == "activity" && x.InstanceId == identity.InstanceId && x.GuildId == identity.GuildId && x.ChannelId == identity.ChannelId && x.ExpiresAt > now, ct); - if (intent is null) + 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) { - intent = 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); - if (intent is null) return Results.Unauthorized(); - intent.ConsumedAt = now; intent.InstanceId = identity.InstanceId; intent.ExpiresAt = now.AddMinutes(15); + 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); } catch (DbUpdateConcurrencyException) { return Results.Conflict(); } + 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 LiveAsync(db, identity.SessionId, ct)) return Results.Unauthorized(); - if (!await PresentAsync(discord, new(identity.UserId, identity.InstanceId!, identity.GuildId, identity.ChannelId), ct)) return Results.Unauthorized(); + 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); @@ -56,9 +81,10 @@ public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRo 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; - if (!await PresentAsync(discord, new(grant.UserId, grant.InstanceId!, grant.GuildId, grant.ChannelId), ct)) return Results.Unauthorized(); 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" }); @@ -79,11 +105,6 @@ public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRo 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 async Task PresentAsync(IDiscordActivityValidator discord, DiscordActivityIdentity identity, CancellationToken ct) - { - try { return await discord.IsPresentAsync(identity, ct); } - catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException or TaskCanceledException) { return false; } - } private static void CopyContext(LaunchCapability source, LaunchCapability target) { target.SessionId = source.SessionId; target.UserId = source.UserId; target.GuildId = source.GuildId; diff --git a/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs index d5857a5..bb7ce3d 100644 --- a/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs @@ -63,11 +63,10 @@ public static IEndpointRouteBuilder MapLaunchIntentEndpoints(this IEndpointRoute db.LaunchCapabilities.Add(intent); await db.SaveChangesAsync(ct); return Results.Ok(new { intent.Id, intent.ExpiresAt }); }); - group.MapGet("/pending", async (AppDbContext db, TimeProvider time, CancellationToken ct) => Results.Ok(new - { - items = await db.LaunchCapabilities.Where(x => x.Kind == "share" && x.ExpiresAt > time.GetUtcNow() && x.MessageId == null) + group.MapGet("/pending", async (AppDbContext db, TimeProvider time, CancellationToken ct) => Results.Ok( + await db.LaunchCapabilities.Where(x => x.Kind == "share" && x.ExpiresAt > time.GetUtcNow() && x.MessageId == null) .Select(x => new { x.Id, x.GuildId, x.ChannelId, requestedByUserId = x.UserId, status = x.SessionId == null ? "pending" : "ready", x.ExpiresAt }).ToListAsync(ct) - })); + )); group.MapGet("/{id:guid}", async (Guid id, int? watchTtlSeconds, AppDbContext db, IOptions options, TimeProvider time, CancellationToken ct) => { var intent = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Id == id && x.Kind == "share" && x.ExpiresAt > time.GetUtcNow(), ct); diff --git a/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs b/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs index 7616143..110fa4b 100644 --- a/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs +++ b/services/SonicRelay.Api/Endpoints/SignalingWebSocketEndpoint.cs @@ -133,6 +133,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 @@ -144,7 +152,7 @@ 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, - activityExpiry.Token); + activityExpiry.Token, activity ? ActivityPresentAsync : null); } finally { @@ -226,16 +234,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 98b560f..6723153 100644 --- a/services/SonicRelay.Api/Program.cs +++ b/services/SonicRelay.Api/Program.cs @@ -90,6 +90,8 @@ builder.Services.Configure(builder.Configuration.GetSection("RelayLaunch")); 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.AddAuthentication("DeviceBearer") .AddScheme("Activity", _ => { }) 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/RelayLaunchService.cs b/services/SonicRelay.Api/Services/RelayLaunchService.cs index 5b3e0e3..dd507ea 100644 --- a/services/SonicRelay.Api/Services/RelayLaunchService.cs +++ b/services/SonicRelay.Api/Services/RelayLaunchService.cs @@ -26,7 +26,7 @@ public interface IDiscordActivityValidator Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct); } -public sealed class DiscordActivityValidator(HttpClient http, Microsoft.Extensions.Options.IOptions options) +public sealed class DiscordActivityValidator(HttpClient http, Microsoft.Extensions.Options.IOptions options, DiscordActivityInstanceCache instances) : IDiscordActivityValidator { public async Task AuthorizeAsync(string code, string instanceId, CancellationToken ct) @@ -59,20 +59,60 @@ public async Task IsPresentAsync(DiscordActivityIdentity identity, Cancell private async Task GetInstanceAsync(string userId, string instanceId, CancellationToken ct) { - var settings = options.Value; - 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 http.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) return null; - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - var root = json.RootElement; - if (root.GetProperty("application_id").GetString() != settings.DiscordClientId - || root.GetProperty("instance_id").GetString() != instanceId - || !root.GetProperty("users").EnumerateArray().Any(x => x.GetString() == userId)) return null; - var location = root.GetProperty("location"); - if (location.GetProperty("kind").GetString() != "gc") return null; - return new(userId, instanceId, location.GetProperty("guild_id").GetString()!, location.GetProperty("channel_id").GetString()!); + 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(); } } } diff --git a/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs b/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs index 5c8b9b6..c8f4c22 100644 --- a/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs @@ -27,6 +27,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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); 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 69d99ff..15e4ddb 100644 --- a/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SonicRelay.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -257,6 +257,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ExpiresAt"); + b.HasIndex("InstanceId") + .IsUnique() + .HasFilter("\"Kind\" = 'activity' AND \"InstanceId\" IS NOT NULL"); + b.HasIndex("TokenHash") .IsUnique(); diff --git a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs index 2fe6891..b9969be 100644 --- a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -1,23 +1,42 @@ 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(true); + public Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => Task.FromResult(Present); } private static SonicRelayApiFactory Factory() => new(new Dictionary { ["RelayLaunch:ServiceToken"] = "bot-secret", ["RelayLaunch:PublicBaseUrl"] = "https://relay.example" }); @@ -31,6 +50,17 @@ private static async Task Json(HttpResponseMessage response) } 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() { @@ -38,6 +68,9 @@ public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_own 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()); var token = new Uri(intent.GetProperty("launchUrl").GetString()!).Fragment[1..]; using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); var redeemed = await Json(await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })); @@ -93,4 +126,74 @@ public async Task Expired_launch_capability_cannot_be_redeemed() clock.Advance(TimeSpan.FromSeconds(31)); Assert.Equal(HttpStatusCode.NotFound, (await host.PostAsJsonAsync("/api/launch-intents/redeem", 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); + } } From fe7a2a47727fdb5c3783edf08e6f7af3050d3335 Mon Sep 17 00:00:00 2001 From: vitorhugo-dotnet Date: Sat, 26 Sep 2026 02:31:45 -0300 Subject: [PATCH 3/5] Allow status polling without minting watch capabilities --- docs/discord-activity-launch-contract.md | 8 ++++++++ .../Endpoints/LaunchIntentEndpoints.cs | 13 ++++++++----- .../LaunchIntentActivityTests.cs | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/discord-activity-launch-contract.md b/docs/discord-activity-launch-contract.md index 95f2b48..428926d 100644 --- a/docs/discord-activity-launch-contract.md +++ b/docs/discord-activity-launch-contract.md @@ -21,6 +21,14 @@ TTL is clamped to 30–900 seconds. Share status is `pending` or `ready`. Capabi 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 diff --git a/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs index bb7ce3d..6d715b0 100644 --- a/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs @@ -76,11 +76,14 @@ await db.LaunchCapabilities.Where(x => x.Kind == "share" && x.ExpiresAt > time.G { var session = await db.StreamSessions.FindAsync([sessionId], ct); if (session is null || session.Status is SessionStatuses.Ended or SessionStatuses.Expired) return Results.NotFound(); - var token = RelayCapability.NewToken(); - var watch = RelayCapability.Create("watch", token, time.GetUtcNow(), watchTtlSeconds ?? 300); - watch.SessionId = sessionId; - db.LaunchCapabilities.Add(watch); await db.SaveChangesAsync(ct); - watchLaunchUrl = Url(options.Value, token); + if (watchTtlSeconds != 0) + { + var token = RelayCapability.NewToken(); + var watch = RelayCapability.Create("watch", token, time.GetUtcNow(), watchTtlSeconds ?? 300); + watch.SessionId = sessionId; + db.LaunchCapabilities.Add(watch); await db.SaveChangesAsync(ct); + watchLaunchUrl = Url(options.Value, token); + } } return Results.Ok(new { intent.Id, status = intent.SessionId == null ? "pending" : "ready", intent.SessionId, watchLaunchUrl, intent.ExpiresAt }); }); diff --git a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs index b9969be..f1cb716 100644 --- a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -81,7 +81,20 @@ public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_own var id = intent.GetProperty("id").GetGuid(); Assert.Equal(HttpStatusCode.NotFound, (await other.PostAsJsonAsync($"/api/launch-intents/{id}/bind", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await host.PostAsJsonAsync($"/api/launch-intents/{id}/bind", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); - var ready = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=60")); Assert.Equal("ready", ready.GetProperty("status").GetString()); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var before = await db.LaunchCapabilities.CountAsync(); + var polling = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=0")); + Assert.Equal("ready", polling.GetProperty("status").GetString()); + Assert.Equal(session.GetProperty("id").GetGuid(), polling.GetProperty("sessionId").GetGuid()); + Assert.Equal(JsonValueKind.Null, polling.GetProperty("watchLaunchUrl").ValueKind); + Assert.Equal(before, await db.LaunchCapabilities.CountAsync()); + var ready = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=60")); + Assert.Equal("ready", ready.GetProperty("status").GetString()); + Assert.Equal(before + 1, await db.LaunchCapabilities.CountAsync()); + 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); + Assert.Equal(before + 2, await db.LaunchCapabilities.CountAsync()); } [Fact] From 912616866e5386205d3554e3325c47e954989bc2 Mon Sep 17 00:00:00 2001 From: vitorhugo-dotnet Date: Sat, 26 Sep 2026 02:38:18 -0300 Subject: [PATCH 4/5] Preserve session_ready launch status expected by Go watcher --- docs/discord-activity-launch-contract.md | 2 +- services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs | 4 ++-- .../LaunchIntentActivityTests.cs | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/discord-activity-launch-contract.md b/docs/discord-activity-launch-contract.md index 428926d..bfb4892 100644 --- a/docs/discord-activity-launch-contract.md +++ b/docs/discord-activity-launch-contract.md @@ -17,7 +17,7 @@ All bot operations use `Authorization: Bearer `: | 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 `ready`. Capability tokens +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. diff --git a/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs index 6d715b0..dbf8ed8 100644 --- a/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/LaunchIntentEndpoints.cs @@ -65,7 +65,7 @@ public static IEndpointRouteBuilder MapLaunchIntentEndpoints(this IEndpointRoute }); group.MapGet("/pending", async (AppDbContext db, TimeProvider time, CancellationToken ct) => Results.Ok( await db.LaunchCapabilities.Where(x => x.Kind == "share" && x.ExpiresAt > time.GetUtcNow() && x.MessageId == null) - .Select(x => new { x.Id, x.GuildId, x.ChannelId, requestedByUserId = x.UserId, status = x.SessionId == null ? "pending" : "ready", x.ExpiresAt }).ToListAsync(ct) + .Select(x => new { x.Id, x.GuildId, x.ChannelId, requestedByUserId = x.UserId, status = x.SessionId == null ? "pending" : "session_ready", x.ExpiresAt }).ToListAsync(ct) )); group.MapGet("/{id:guid}", async (Guid id, int? watchTtlSeconds, AppDbContext db, IOptions options, TimeProvider time, CancellationToken ct) => { @@ -85,7 +85,7 @@ await db.LaunchCapabilities.Where(x => x.Kind == "share" && x.ExpiresAt > time.G watchLaunchUrl = Url(options.Value, token); } } - return Results.Ok(new { intent.Id, status = intent.SessionId == null ? "pending" : "ready", intent.SessionId, watchLaunchUrl, intent.ExpiresAt }); + return Results.Ok(new { intent.Id, status = intent.SessionId == null ? "pending" : "session_ready", intent.SessionId, watchLaunchUrl, intent.ExpiresAt }); }); group.MapPost("/{id:guid}/published", async (Guid id, PublishedRequest request, AppDbContext db, TimeProvider time, CancellationToken ct) => { diff --git a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs index f1cb716..7484eec 100644 --- a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -71,6 +71,7 @@ public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_own 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 token = new Uri(intent.GetProperty("launchUrl").GetString()!).Fragment[1..]; using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); var redeemed = await Json(await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })); @@ -84,17 +85,19 @@ public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_own using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var before = await db.LaunchCapabilities.CountAsync(); var polling = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=0")); - Assert.Equal("ready", polling.GetProperty("status").GetString()); + Assert.Equal("session_ready", polling.GetProperty("status").GetString()); Assert.Equal(session.GetProperty("id").GetGuid(), polling.GetProperty("sessionId").GetGuid()); Assert.Equal(JsonValueKind.Null, polling.GetProperty("watchLaunchUrl").ValueKind); Assert.Equal(before, await db.LaunchCapabilities.CountAsync()); var ready = await Json(await bot.GetAsync($"/api/launch-intents/{id}?watchTtlSeconds=60")); - Assert.Equal("ready", ready.GetProperty("status").GetString()); + Assert.Equal("session_ready", ready.GetProperty("status").GetString()); Assert.Equal(before + 1, await db.LaunchCapabilities.CountAsync()); 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); Assert.Equal(before + 2, await db.LaunchCapabilities.CountAsync()); + var readyItems = await Json(await bot.GetAsync("/api/launch-intents/pending")); + Assert.Equal("session_ready", readyItems[0].GetProperty("status").GetString()); } [Fact] From 3c2c1b38580074a4d378402fe25e5df1ada6c465 Mon Sep 17 00:00:00 2001 From: vitorhugo-dotnet Date: Sat, 26 Sep 2026 16:16:48 -0300 Subject: [PATCH 5/5] Fix launch intent activity integration tests --- .../LaunchIntentActivityTests.cs | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs index 7484eec..399b8ac 100644 --- a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -39,7 +39,7 @@ private sealed class Discord : IDiscordActivityValidator public Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => Task.FromResult(Present); } private static SonicRelayApiFactory Factory() => new(new Dictionary - { ["RelayLaunch:ServiceToken"] = "bot-secret", ["RelayLaunch:PublicBaseUrl"] = "https://relay.example" }); + { ["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; @@ -72,30 +72,23 @@ public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_own 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 token = new Uri(intent.GetProperty("launchUrl").GetString()!).Fragment[1..]; + 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); - var redeemed = await Json(await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })); - Assert.Equal("share", redeemed.GetProperty("kind").GetString()); - Assert.Equal(HttpStatusCode.NotFound, (await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })).StatusCode); + 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); - using var other = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(other, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); var id = intent.GetProperty("id").GetGuid(); - Assert.Equal(HttpStatusCode.NotFound, (await other.PostAsJsonAsync($"/api/launch-intents/{id}/bind", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); - Assert.Equal(HttpStatusCode.NoContent, (await host.PostAsJsonAsync($"/api/launch-intents/{id}/bind", new { sessionId = session.GetProperty("id").GetGuid() })).StatusCode); - using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var before = await db.LaunchCapabilities.CountAsync(); + 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.Null, polling.GetProperty("watchLaunchUrl").ValueKind); - Assert.Equal(before, await db.LaunchCapabilities.CountAsync()); + 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(before + 1, await db.LaunchCapabilities.CountAsync()); 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); - Assert.Equal(before + 2, await db.LaunchCapabilities.CountAsync()); var readyItems = await Json(await bot.GetAsync("/api/launch-intents/pending")); Assert.Equal("session_ready", readyItems[0].GetProperty("status").GetString()); } @@ -134,13 +127,14 @@ public async Task Activity_requires_invoker_bootstrap_and_enforces_single_use_gr public async Task Expired_launch_capability_cannot_be_redeemed() { var clock = new TestTimeProvider(); - using var factory = new SonicRelayApiFactory(new Dictionary { ["RelayLaunch:ServiceToken"] = "bot-secret" }) { TimeProviderOverride = clock }; + 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 token = intent.GetProperty("launchUrl").GetString()!.Split('#')[1]; - using var host = factory.CreateClient(); await DeviceIdentityTestHelper.BootstrapAndAuthorizeAsync(host, DeviceTypes.WindowsDesktop, DevicePlatforms.Windows); + 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.NotFound, (await host.PostAsJsonAsync("/api/launch-intents/redeem", new { token })).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await host.PostAsJsonAsync("/api/launch-intents/share/consume", new { token })).StatusCode); } [Fact]