Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# RelayControl

Shared control plane for SonicRelay and FrameRelay. RelayControl provides device identity, pairing, sessions, join codes, authenticated WebSocket signaling and TURN credential issuance using ASP.NET Core Minimal API, PostgreSQL and Redis. WebRTC media stays between clients, directly or through coturn; this API never carries, transcodes or stores the media itself.

## Project suite

| Project | Repository | Stack | Responsibility |
Expand Down
79 changes: 79 additions & 0 deletions docs/discord-activity-launch-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# FrameRelay launch and Discord Activity contract

Configure `RelayLaunch:ServiceToken` (a separate high entropy bot-only bearer), `PublicBaseUrl`
(public HTTPS API origin), `DownloadUrl`, `DiscordClientId`, `DiscordClientSecret`,
`DiscordBotToken` and optional `DiscordRedirectUri`. Keep both Discord secrets API-side.
Apply `AddLaunchCapabilities` and `EnforceActivityInstanceBinding` before enabling bot routes. Existing device session routes remain
DeviceBearer authenticated. Bot credentials cannot create/join sessions.

All bot operations use `Authorization: Bearer <RelayLaunch:ServiceToken>`:

| Request | Body / query | Response |
|---|---|---|
| POST /api/launch-intents/share | `{provider:"discord",guildId,channelId,requestedByUserId,ttlSeconds}` | `{id,launchUrl,expiresAt}` |
| GET /api/launch-intents/pending | none | `[{id,guildId,channelId,requestedByUserId,status,expiresAt}]` |
| GET /api/launch-intents/{id} | `watchTtlSeconds` optional | `{id,status,sessionId,watchLaunchUrl,expiresAt}` |
| POST /api/launch-intents/{id}/published | `{messageId?}` | 204 |
| POST /api/launch-intents/watch | `{code,ttlSeconds}` | `{launchUrl,expiresAt}` |
| POST /api/launch-intents/activity | `{code,guildId,channelId,requestedByUserId,ttlSeconds}` | `{id,expiresAt}` |

TTL is clamped to 30–900 seconds. Share status is `pending` or `session_ready`, matching the Go watcher contract. Capability tokens
are 64 lowercase hex characters and only hashes are stored; Activity bootstrap tokens are
retained exclusively server-side. Watch only admits live screen-share codes.

`GET /api/launch-intents/{id}?watchTtlSeconds=0` opts out of creating a watch capability:
it returns the existing ready/status/session fields and `watchLaunchUrl: null`. Omitted TTL
still creates a 300-second watch capability; positive TTL keeps the normal clamped behavior.
The bot uses zero during polling because a public ready announcement cannot share a
single-use capability among participants. Each participant runs `/framerelay watch <code>`
to obtain an individual Activity intent and individual desktop fallback, so status polling
does not create unused credentials.

Public URLs are `/open/launch#<token>`: the fragment is absent from HTTP access logs. The landing
page clears it from history and opens `framerelay://launch?token=<token>`, and shows the configured
download/help link. The operating system cannot reliably report whether a protocol handler is
installed, so the download/help link stays visible rather than claiming automatic detection.

Desktop uses its own DeviceBearer for `POST /api/launch-intents/redeem {token}` →
`{id,kind,code,sessionId}`. Redemption is single-use. `kind=share` starts the normal screen
session, then `POST /api/launch-intents/{id}/bind {sessionId}` → 204; API checks both the
redeeming device and session source ownership. `kind=watch` uses `code` where present or the
sessionId for an owner-announcement watch link. No bot-supplied session ownership is trusted.

The Activity SDK authorizes with `identify`. It submits its authorization code and SDK instance:

1. `POST /api/discord/activity/authorize {code,instanceId}` →
`{accessToken,userId,instanceId,expiresAt}`. This accessToken is an opaque FrameRelay identity
credential, never the Discord OAuth token. API exchanges the code and calls Discord `/users/@me`,
then bot-authenticated `GET /applications/{application.id}/activity-instances/{instance_id}`.
Application, instance, current user membership, guild and channel must match. The invoking user
binds the newest pending context intent once; later users may authenticate in that bound instance.
An instance can bind one active session. A new watch for a different session returns 409
`activity_instance_busy` while that session remains live, so it cannot silently show old media.
When the previous session ends or the binding expires, a new pending intent can replace it;
replacement expires all old instance/session credentials. A filtered unique database index,
instance lock and serializable transaction protect concurrent initial binding.
2. `POST /api/discord/activity/viewer-grants` with identity bearer and no body → `{grant,expiresAt}`.
Revalidates current Discord membership. Grant lasts 60 seconds and works once.
3. `POST /api/discord/activity/viewer-grants/redeem {grant}` →
`{sessionId,participantId,signalingToken,expiresAt,iceServers}`. Counts the participant against
the existing session viewer limit. No host/device credential or participant selector is accepted.
4. Connect `/ws/signaling?sessionId=<sessionId>` with WebSocket subprotocols
`["framerelay","token."+signalingToken]`. The token works for this session only and is single-use;
the server negotiates `framerelay`. Existing signaling envelope/SDP/ICE shapes are unchanged.

Bound instances and viewer signaling grants last 15 minutes; sockets close at credential expiry,
session end or Activity disconnect, and expired unused viewer reservations are cleaned every 30s.
Presence is checked during signaling admission and every five seconds thereafter using one
coalesced Discord REST snapshot per instance every five seconds. A snapshot that omits users
revokes their identity/grant/signaling credentials together; their sockets close and viewer slots
are released. Discord lookup failure fails closed. Disconnected Activity viewers do not use the desktop reconnect grace period. Obtain a new grant
to reconnect while the bound Activity authorization remains valid.

Secrets must not be included in request body or header logging. Activity requires reachable HTTPS
hosting, Discord Activities enabled, Activity URL Mapping to the client/API/WebSocket host, and
the same Discord application ID for bot, SDK and API. No RTP passes through this API.

The official validation contract is documented at
https://docs.discord.com/developers/resources/application#get-application-activity-instance.
Deployment and real Discord/TURN/H.264/Opus playback require manual validation.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SonicRelay.Api.Services;
using SonicRelay.Domain.Sessions;
using SonicRelay.Infrastructure.Persistence;

namespace SonicRelay.Api.Authorization;

public sealed class ActivityAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, AppDbContext db, TimeProvider time, IDiscordActivityValidator discord)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (Request.Path != "/ws/signaling") return AuthenticateResult.NoResult();
var protocol = Request.Headers.SecWebSocketProtocol.ToString().Split(',').Select(x => x.Trim()).FirstOrDefault(x => x.StartsWith("token."));
if (protocol is null) return AuthenticateResult.NoResult();
if (!Request.Headers.SecWebSocketProtocol.ToString().Split(',').Any(x => x.Trim() == "framerelay")) return AuthenticateResult.Fail("Missing Activity protocol.");
var cap = await RelayCapability.FindAsync(db, protocol[6..], "signaling", time.GetUtcNow(), Context.RequestAborted);
if (cap?.SessionId is not { } sessionId || cap.DeviceId is null || Request.Query["sessionId"] != sessionId.ToString()) return AuthenticateResult.Fail("Invalid Activity credential.");
if (!await db.StreamSessions.AnyAsync(x => x.Id == sessionId && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, Context.RequestAborted)) return AuthenticateResult.Fail("Session ended.");
if (!await ActivityPresenceService.IsAuthorizedAsync(db, discord, cap, time.GetUtcNow(), Context.RequestAborted)) return AuthenticateResult.Fail("Activity membership revoked.");
cap.ConsumedAt = time.GetUtcNow();
try { await db.SaveChangesAsync(Context.RequestAborted); } catch (DbUpdateConcurrencyException) { return AuthenticateResult.Fail("Credential consumed."); }
var claims = new[] { new Claim("sub", cap.DeviceId.Value.ToString()), new Claim("activity_session", sessionId.ToString()), new Claim("activity_expiry", cap.ExpiresAt.ToUnixTimeSeconds().ToString()), new Claim("activity_capability", cap.Id.ToString()) };
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)), Scheme.Name));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
113 changes: 113 additions & 0 deletions services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using SonicRelay.Api.Services;
using SonicRelay.Application.Abstractions;
using SonicRelay.Domain.DeviceIdentities;
using SonicRelay.Domain.Sessions;
using SonicRelay.Infrastructure.Persistence;

namespace SonicRelay.Api.Endpoints;

public static class DiscordActivityEndpoints
{
public sealed record AuthorizeRequest(string Code, string InstanceId);
public sealed record GrantRequest(string Grant);
public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/discord/activity").RequireRateLimiting("join-session");
group.MapPost("/authorize", async (AuthorizeRequest request, IDiscordActivityValidator discord, AppDbContext db, IParticipantAdmissionLock admissionLock, TimeProvider time, CancellationToken ct) =>
{
DiscordActivityIdentity? identity;
try { identity = await discord.AuthorizeAsync(request.Code, request.InstanceId, ct); }
catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException or TaskCanceledException) { return Results.Unauthorized(); }
if (identity is null) return Results.Unauthorized();
var now = time.GetUtcNow();
var instanceLock = new Guid(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(identity.InstanceId)).AsSpan(0, 16));
using var binding = await admissionLock.AcquireAsync(instanceLock, Guid.Empty, ct);
await using var transaction = db.Database.IsRelational()
? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null;
var bound = await db.LaunchCapabilities.SingleOrDefaultAsync(x => x.Kind == "activity" && x.InstanceId == identity.InstanceId, ct);
var pending = await db.LaunchCapabilities.Where(x => x.Kind == "activity" && x.InstanceId == null && x.ConsumedAt == null && x.UserId == identity.UserId && x.GuildId == identity.GuildId && x.ChannelId == identity.ChannelId && x.ExpiresAt > now).OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
var boundLive = bound is not null && bound.ExpiresAt > now && await LiveAsync(db, bound.SessionId, ct);
if (pending is not null && boundLive && pending.SessionId != bound!.SessionId)
return Results.Conflict(new { code = "activity_instance_busy" });
LaunchCapability? intent;
if (pending is not null)
{
if (!await LiveAsync(db, pending.SessionId, ct)) return Results.Unauthorized();
pending.ConsumedAt = now;
if (!boundLive)
{
if (bound is not null)
{
bound.InstanceId = null;
var stale = await db.LaunchCapabilities.Where(x => x.InstanceId == identity.InstanceId).ToListAsync(ct);
foreach (var cap in stale) cap.ExpiresAt = now;
await db.SaveChangesAsync(ct);
}
pending.InstanceId = identity.InstanceId; pending.ExpiresAt = now.AddMinutes(15);
intent = pending;
}
else intent = bound;
}
else intent = boundLive ? bound : null;
if (intent is null || intent.GuildId != identity.GuildId || intent.ChannelId != identity.ChannelId) return Results.Unauthorized();
if (!await LiveAsync(db, intent.SessionId, ct)) return Results.Unauthorized();
var token = RelayCapability.NewToken();
var credential = RelayCapability.Create("identity", token, now, 900);
CopyContext(intent, credential); credential.UserId = identity.UserId;
db.LaunchCapabilities.Add(credential);
try
{
await db.SaveChangesAsync(ct);
if (transaction is not null) await transaction.CommitAsync(ct);
}
catch (DbUpdateException) { return Results.Conflict(new { code = "activity_binding_conflict" }); }
return Results.Ok(new { accessToken = token, identity.UserId, identity.InstanceId, credential.ExpiresAt });
});
group.MapPost("/viewer-grants", async (HttpContext context, AppDbContext db, IDiscordActivityValidator discord, TimeProvider time, CancellationToken ct) =>
{
var header = context.Request.Headers.Authorization.ToString();
var identity = await RelayCapability.FindAsync(db, header.StartsWith("Bearer ") ? header[7..] : "", "identity", time.GetUtcNow(), ct);
if (identity is null || !await ActivityPresenceService.IsAuthorizedAsync(db, discord, identity, time.GetUtcNow(), ct)) return Results.Unauthorized();
var token = RelayCapability.NewToken();
var grant = RelayCapability.Create("grant", token, time.GetUtcNow(), 60);
CopyContext(identity, grant); db.LaunchCapabilities.Add(grant); await db.SaveChangesAsync(ct);
return Results.Ok(new { grant = token, grant.ExpiresAt });
});
group.MapPost("/viewer-grants/redeem", async (GrantRequest request, AppDbContext db, IDiscordActivityValidator discord,
IParticipantAdmissionLock admissionLock, TurnCredentialService turn, TimeProvider time, CancellationToken ct) =>
{
var now = time.GetUtcNow();
var grant = await RelayCapability.FindAsync(db, request.Grant, "grant", now, ct);
if (grant?.SessionId is not { } sessionId) return Results.Unauthorized();
using var admission = await admissionLock.AcquireAsync(sessionId, Guid.Empty, ct);
now = time.GetUtcNow();
if (!await ActivityPresenceService.IsAuthorizedAsync(db, discord, grant, now, ct)) return Results.Unauthorized();
await using var transaction = db.Database.IsRelational()
? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct) : null;
var session = await db.StreamSessions.SingleOrDefaultAsync(x => x.Id == sessionId, ct);
if (session is null || session.Status is SessionStatuses.Ended or SessionStatuses.Expired) return Results.Unauthorized();
if (await db.SessionParticipants.CountAsync(x => x.SessionId == sessionId && x.Role == ParticipantRoles.Viewer && (x.Status == ParticipantStatuses.Connected || x.Status == ParticipantStatuses.Reconnecting), ct) >= session.MaxViewers) return Results.Conflict(new { code = "viewer_limit" });
var device = new DeviceIdentity { Id = Guid.NewGuid(), Name = "Discord Activity viewer", DeviceType = "discord_activity", Platform = "web", CreatedAt = now };
var participant = new SessionParticipant { Id = Guid.NewGuid(), DeviceId = device.Id, SessionId = sessionId, JoinedAt = now };
SessionAudioPolicy.ApplyDefaults(participant, SessionModes.ScreenShare);
grant.ConsumedAt = now;
var token = RelayCapability.NewToken();
var signal = RelayCapability.Create("signaling", token, now, 900);
CopyContext(grant, signal); signal.DeviceId = device.Id; signal.ParticipantId = participant.Id;
db.DeviceIdentities.Add(device); db.SessionParticipants.Add(participant); db.LaunchCapabilities.Add(signal);
session.Status = SessionStatuses.Active; session.StartedAt ??= now;
try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { return Results.Conflict(); }
if (transaction is not null) await transaction.CommitAsync(ct);
var ice = await turn.BuildAsync(device.Id.ToString(), ct);
return Results.Ok(new { sessionId, participantId = participant.Id, signalingToken = token, signal.ExpiresAt, ice.IceServers });
});
return app;
}
private static Task<bool> LiveAsync(AppDbContext db, Guid? id, CancellationToken ct) => db.StreamSessions.AnyAsync(x => x.Id == id && x.Mode == SessionModes.ScreenShare && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct);
private static void CopyContext(LaunchCapability source, LaunchCapability target)
{
target.SessionId = source.SessionId; target.UserId = source.UserId; target.GuildId = source.GuildId;
target.ChannelId = source.ChannelId; target.InstanceId = source.InstanceId;
}
}
Loading
Loading