diff --git a/Musify.StreamingGateway.slnx b/Musify.StreamingGateway.slnx
new file mode 100644
index 0000000..3626bbd
--- /dev/null
+++ b/Musify.StreamingGateway.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/StreamingGateway/Authentication/TicketValidator.cs b/StreamingGateway/Authentication/TicketValidator.cs
new file mode 100644
index 0000000..5556e42
--- /dev/null
+++ b/StreamingGateway/Authentication/TicketValidator.cs
@@ -0,0 +1,52 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Musify.StreamingGateway.Configuration;
+
+namespace Musify.StreamingGateway.Authentication;
+
+public sealed class TicketValidator : IDisposable
+{
+ private readonly StreamTicketValidationOptions options;
+ private readonly RSA rsa;
+ private readonly TokenValidationParameters validationParameters;
+ private readonly JsonWebTokenHandler handler = new();
+
+ public TicketValidator(IOptions options)
+ {
+ this.options = options.Value;
+
+ rsa = RSA.Create();
+ rsa.ImportFromPem(File.ReadAllText(this.options.PublicKeyPath));
+
+ validationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = this.options.Issuer,
+ ValidateAudience = true,
+ ValidAudience = this.options.Audience,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new RsaSecurityKey(rsa),
+ ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
+ ClockSkew = TimeSpan.FromSeconds(30),
+ };
+ }
+
+ public async Task TryGetPrefixAsync(string? token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ return null;
+
+ var result = await handler.ValidateTokenAsync(token, validationParameters);
+ if (!result.IsValid)
+ return null;
+
+ return result.Claims.TryGetValue("prefix", out var prefix)
+ ? prefix as string
+ : null;
+ }
+
+ public void Dispose() => rsa.Dispose();
+}
diff --git a/StreamingGateway/Configuration/StreamTicketValidationOptions.cs b/StreamingGateway/Configuration/StreamTicketValidationOptions.cs
new file mode 100644
index 0000000..7599c06
--- /dev/null
+++ b/StreamingGateway/Configuration/StreamTicketValidationOptions.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Musify.StreamingGateway.Configuration;
+
+public sealed class StreamTicketValidationOptions
+{
+ public const string SectionName = "StreamTicket";
+
+ [Required]
+ public required string PublicKeyPath { get; set; }
+
+ [Required]
+ public string Audience { get; set; } = "media-gateway";
+
+ [Required]
+ public string Issuer { get; set; } = "musify-webapi";
+
+ public string QueryParameterName { get; set; } = "t";
+
+ public string MediaPathPrefix { get; set; } = "/media";
+}
diff --git a/StreamingGateway/Middleware/TicketValidationMiddleware.cs b/StreamingGateway/Middleware/TicketValidationMiddleware.cs
new file mode 100644
index 0000000..cba1fc3
--- /dev/null
+++ b/StreamingGateway/Middleware/TicketValidationMiddleware.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Options;
+using Musify.StreamingGateway.Authentication;
+using Musify.StreamingGateway.Configuration;
+
+namespace Musify.StreamingGateway.Middleware;
+
+public sealed class TicketValidationMiddleware(
+ RequestDelegate next,
+ TicketValidator ticketValidator,
+ IOptions options,
+ ILogger logger)
+{
+ private readonly StreamTicketValidationOptions options = options.Value;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var path = context.Request.Path;
+
+ if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining))
+ {
+ await next(context);
+ return;
+ }
+
+ var token = context.Request.Query[options.QueryParameterName].ToString();
+ var prefix = await ticketValidator.TryGetPrefixAsync(token);
+
+ if (prefix is null)
+ {
+ logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
+ if (!objectKey.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix);
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ StripQueryParameter(context, options.QueryParameterName);
+
+ await next(context);
+ }
+
+ private static void StripQueryParameter(HttpContext context, string name)
+ {
+ var remaining = context.Request.Query
+ .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal))
+ .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value)))
+ .ToList();
+
+ context.Request.QueryString = QueryString.Create(remaining);
+ }
+}
diff --git a/StreamingGateway/Program.cs b/StreamingGateway/Program.cs
new file mode 100644
index 0000000..4e03270
--- /dev/null
+++ b/StreamingGateway/Program.cs
@@ -0,0 +1,35 @@
+using Musify.StreamingGateway.Authentication;
+using Musify.StreamingGateway.Configuration;
+using Musify.StreamingGateway.Middleware;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services
+ .AddOptions()
+ .Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationOptions.SectionName))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+builder.Services.AddSingleton();
+
+const string corsPolicy = "media-clients";
+var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? [];
+builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
+ .WithOrigins(allowedOrigins)
+ .AllowAnyHeader()
+ .WithMethods("GET", "HEAD")
+ .WithExposedHeaders("Content-Range", "Accept-Ranges", "Content-Length")));
+
+builder.Services
+ .AddReverseProxy()
+ .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
+
+var app = builder.Build();
+
+app.MapGet("/health", () => Results.Ok("healthy"));
+
+app.UseCors(corsPolicy);
+app.UseMiddleware();
+app.MapReverseProxy();
+
+app.Run();
diff --git a/StreamingGateway/Properties/launchSettings.json b/StreamingGateway/Properties/launchSettings.json
new file mode 100644
index 0000000..eaf1884
--- /dev/null
+++ b/StreamingGateway/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "StreamingGateway": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:8081",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/StreamingGateway/StreamingGateway.csproj b/StreamingGateway/StreamingGateway.csproj
new file mode 100644
index 0000000..bc16ead
--- /dev/null
+++ b/StreamingGateway/StreamingGateway.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Musify.StreamingGateway
+ media-gateway-2f1c9b6a-7d3e-4c0a-9a21-0b8e5d4c3f10
+
+
+
+
+
+
+
+
diff --git a/StreamingGateway/appsettings.json b/StreamingGateway/appsettings.json
new file mode 100644
index 0000000..85e3e89
--- /dev/null
+++ b/StreamingGateway/appsettings.json
@@ -0,0 +1,41 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Yarp": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+
+ "StreamTicket": {
+ "PublicKeyPath": "D:\\weed\\stream_public.pem",
+ "Audience": "media-gateway",
+ "Issuer": "musify-webapi",
+ "QueryParameterName": "t",
+ "MediaPathPrefix": "/media"
+ },
+
+ "Cors": {
+ "AllowedOrigins": [ "http://localhost:5173", "http://localhost:3000" ]
+ },
+
+ "ReverseProxy": {
+ "Routes": {
+ "media": {
+ "ClusterId": "seaweed-filer",
+ "Match": { "Path": "/media/{**rest}" },
+ "Transforms": [
+ { "PathPattern": "/buckets/webapi-storage/{**rest}" }
+ ]
+ }
+ },
+ "Clusters": {
+ "seaweed-filer": {
+ "Destinations": {
+ "filer": { "Address": "http://localhost:8888/" }
+ }
+ }
+ }
+ }
+}