feat(admin): add prefixed WebUI and linked OIDC login

This commit is contained in:
joshpatra committed 2026-09-27 18:05:52 -04:00
1 parent 78e305ab1f
commit 6fdd20bb9b
53 files changed
+7977 -105

No files matched your search

+9
View File
@@ -32,6 +32,15 @@ ADMIN_PORT=5275
# Explicit host security boundaries.
ADMIN_BIND_ANY_IP=false
ADMIN_TRUSTED_SUBNETS=
# Optional admin mount point; empty keeps the dashboard at /.
ADMIN_BASE_PATH=
# WebUI SSO is opt-in. Public URL must use HTTPS and include ADMIN_BASE_PATH.
ADMIN_OIDC_ENABLED=false
ADMIN_OIDC_AUTHORITY=
ADMIN_OIDC_CLIENT_ID=
ADMIN_OIDC_CLIENT_SECRET=
ADMIN_OIDC_PUBLIC_URL=
ADMIN_OIDC_DISPLAY_NAME=Single sign-on
EXTENSIONS_ALLOW_REMOTE_INSTALL=false
CORS_ALLOWED_ORIGINS=
CORS_ALLOW_CREDENTIALS=false
@@ -46,7 +46,7 @@ public class AdminAuthControllerTests
var sessionService = AdminAuthSessionTestSupport.Create();
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["X-Forwarded-Proto"] = "https";
httpContext.Request.Scheme = "https";
var controller = CreateController(handler, sessionService, httpContext);
var result = await controller.Login(new AdminAuthController.LoginRequest
@@ -0,0 +1,122 @@
using allstarr.Middleware;
using allstarr.Services.Admin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace allstarr.Tests;
public class AdminBasePathMiddlewareTests
{
[Theory]
[InlineData(null, "")]
[InlineData("", "")]
[InlineData("/", "")]
[InlineData("/admin", "/admin")]
[InlineData("/admin/", "/admin")]
[InlineData("/ops_2/music-admin/", "/ops_2/music-admin")]
public void Normalize_AcceptsSafeSegments(string? configured, string expected)
{
Assert.Equal(expected, AdminBasePath.Normalize(configured));
}
[Theory]
[InlineData("admin")]
[InlineData("//admin")]
[InlineData("/admin//")]
[InlineData("/admin/../")]
[InlineData("/admin/%2e%2e")]
[InlineData("/admin?return=1")]
[InlineData("https://example.test/admin")]
[InlineData("/admin space")]
public void Normalize_RejectsMalformedOrExternalPrefixes(string configured)
{
Assert.Throws<ArgumentException>(() => AdminBasePath.Normalize(configured));
}
[Fact]
public async Task InvokeAsync_PreservedPrefix_SetsPathBaseAndStripsPath()
{
var middleware = CreateMiddleware("/admin", out var nextPathBase, out var nextPath);
var context = CreateContext(5275, "/admin/api/admin/ui/home");
await middleware.InvokeAsync(context);
Assert.Equal("/admin", nextPathBase());
Assert.Equal("/api/admin/ui/home", nextPath());
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_StrippedPrefix_RestoresPathBaseWithoutChangingPath()
{
var middleware = CreateMiddleware("/admin", out var nextPathBase, out var nextPath);
var context = CreateContext(5275, "/api/admin/ui/home");
await middleware.InvokeAsync(context);
Assert.Equal("/admin", nextPathBase());
Assert.Equal("/api/admin/ui/home", nextPath());
}
[Fact]
public async Task InvokeAsync_ExactPrefix_RedirectsWithQueryString()
{
var middleware = CreateMiddleware("/admin", out var nextPathBase, out _);
var context = CreateContext(5275, "/admin");
context.Request.QueryString = new QueryString("?return=%2Fmusic");
await middleware.InvokeAsync(context);
Assert.Equal(StatusCodes.Status308PermanentRedirect, context.Response.StatusCode);
Assert.Equal("/admin/?return=%2Fmusic", context.Response.Headers.Location.ToString());
Assert.True(string.IsNullOrEmpty(nextPathBase()));
}
[Fact]
public async Task InvokeAsync_NonAdminPort_DoesNotSetPathBase()
{
var middleware = CreateMiddleware("/admin", out var nextPathBase, out var nextPath);
var context = CreateContext(8080, "/admin/api/admin/ui/home");
await middleware.InvokeAsync(context);
Assert.True(string.IsNullOrEmpty(nextPathBase()));
Assert.Equal("/admin/api/admin/ui/home", nextPath());
}
private static AdminBasePathMiddleware CreateMiddleware(
string configured,
out Func<string?> nextPathBase,
out Func<string?> nextPath)
{
string? observedPathBase = null;
string? observedPath = null;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AdminBasePath.ConfigurationKey] = configured,
})
.Build();
var basePath = new AdminBasePath(configuration);
nextPathBase = () => observedPathBase;
nextPath = () => observedPath;
return new AdminBasePathMiddleware(context =>
{
observedPathBase = context.Request.PathBase.Value;
observedPath = context.Request.Path.Value;
context.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
}, basePath);
}
private static DefaultHttpContext CreateContext(int localPort, string path)
{
var context = new DefaultHttpContext();
context.Connection.LocalPort = localPort;
context.Request.Method = HttpMethods.Get;
context.Request.Path = path;
context.Response.Body = new MemoryStream();
return context;
}
}
+167
View File
@@ -0,0 +1,167 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using allstarr.Core.Identity;
using allstarr.Core.Operations;
using allstarr.Core.Secrets;
using allstarr.Core.Storage;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Controllers;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
namespace allstarr.Tests;
public sealed class AdminOidcLinksTests
{
[Theory]
[InlineData("jellyfin")]
[InlineData("subsonic")]
public async Task LinkRequiresExactIdentity_RevalidatesBackend_AndRevokesSessions(string backendName)
{
await using var database = await PostgresTestDatabase.CreateAsync();
IDbContextFactory<AllstarrDbContext> factory = new Factory(database.Options);
var options = new IdentityOptions();
var state = new DurableStorageState(new() { Provider = "Postgres", ConnectionString = database.ConnectionString });
state.Set(DurableStorageReadiness.Ready);
var identities = new BackendIdentityResolver(factory, state, options, new SystemPlatformClock());
var alice = (await identities.ResolveAsync(new(backendName, "alice", "Alice")))!;
var bob = (await identities.ResolveAsync(new(backendName, "bob", "Bob")))!;
var root = Directory.CreateTempSubdirectory("allstarr-oidc-tests-");
try
{
var secretOptions = new SecretStoreOptions { KeyRingPath = Path.Combine(root.FullName, "keys.json") };
await File.WriteAllTextAsync(secretOptions.KeyRingPath, JsonSerializer.Serialize(new
{
activeKeyId = "fixture",
keys = new Dictionary<string, string> { ["fixture"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) }
}));
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(secretOptions.KeyRingPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
var secrets = new EncryptedSecretStore(factory, new(secretOptions), secretOptions, new SystemPlatformClock());
var oidc = new AdminOidcOptions { Enabled = true };
var handler = new NativeHandler(backendName);
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Backend:Type"] = backendName }).Build();
var jellyfin = new JellyfinSettings { Url = "http://native.test" };
var subsonic = new SubsonicSettings { Url = "http://native.test" };
var native = new AdminOidcBackendAuthentication(configuration, Options.Create(jellyfin), Options.Create(subsonic), new Clients(handler));
var sessions = new AdminAuthSessionService(new EfAdminAuthSessionStore(factory), new EphemeralDataProtectionProvider(),
NullLogger<AdminAuthSessionService>.Instance, factory, oidc, native, options);
var links = new AdminOidcLinks(factory, secrets, options, native, identities, sessions);
var credential = new AdminOidcCredential(backendName, "http://native.test", "alice", "private-fixture-credential");
var key = new string('a', 64);
await links.LinkAsync(key, alice, credential, default);
await using (var db = await factory.CreateDbContextAsync())
{
Assert.Single(await db.AdminOidcLinks.ToListAsync());
var version = Assert.Single(await db.SecretVersions.ToListAsync());
Assert.DoesNotContain("private-fixture-credential", Encoding.UTF8.GetString(version.Ciphertext));
}
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => links.LinkAsync(key, bob, credential with { UserId = "bob" }, default));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => links.LinkAsync(new string('b', 64), alice, credential, default));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => links.LinkAsync(key, alice, credential with { Endpoint = "http://other.test" }, default));
handler.Admin = true;
var session = await links.SignInAsync(key, default);
Assert.NotNull(session);
Assert.Equal(backendName == "jellyfin" ? "Jellyfin" : "Subsonic", session.BackendType);
Assert.True(session.IsAdministrator);
Assert.Equal(alice.UserId, session.AllstarrUserId);
Assert.NotNull(await sessions.GetValidSessionAsync(session.SessionId));
handler.Admin = false;
Assert.False((await links.SignInAsync(key, default))!.IsAdministrator);
handler.Accept = false;
Assert.Null(await links.SignInAsync(key, default));
handler.Accept = true;
handler.UserId = "bob";
Assert.Null(await links.SignInAsync(key, default));
handler.UserId = "alice";
var calls = handler.Calls;
jellyfin.Url = subsonic.Url = "http://replacement.test";
Assert.Null(await links.SignInAsync(key, default));
Assert.Equal(calls, handler.Calls);
Assert.Null(await sessions.GetValidSessionAsync(session.SessionId));
jellyfin.Url = subsonic.Url = "http://native.test";
var bobSession = await sessions.CreateSessionAsync("bob", "Bob", false, "", null,
backendType: backendName, tenantId: bob.TenantId, allstarrUserId: bob.UserId);
Assert.False(await links.IsLinkedAsync(bobSession, default));
await links.UnlinkAsync(bobSession, default);
Assert.True(await links.IsLinkedAsync(session, default));
var csrf = new Mock<IAntiforgery>();
csrf.Setup(item => item.IsRequestValidAsync(It.IsAny<HttpContext>())).ReturnsAsync(false);
var requestContext = new DefaultHttpContext();
requestContext.Request.Scheme = "https";
requestContext.Request.Headers.Cookie = AdminAuthSessionService.SessionCookieName + "=" + session.SessionId;
var oidcController = new AdminOidcController(oidc, links, sessions, csrf.Object, NullLogger<AdminOidcController>.Instance)
{
ControllerContext = new() { HttpContext = requestContext }
};
Assert.IsType<BadRequestObjectResult>(await oidcController.Unlink(default));
var authController = new AdminAuthController(Options.Create(jellyfin), Options.Create(subsonic), configuration,
new Clients(handler), sessions, NullLogger<AdminAuthController>.Instance, null!, identities,
oidcOptions: oidc, oidcLinks: links, antiforgery: csrf.Object)
{
ControllerContext = new() { HttpContext = requestContext }
};
Assert.IsType<BadRequestObjectResult>(await authController.Login(new() { Username = "alice", Password = "fixture", LinkOidc = true }));
Assert.True(await links.IsLinkedAsync(session, default));
await links.UnlinkAsync(session, default);
Assert.Null(await links.SignInAsync(key, default));
Assert.Null(await sessions.GetValidSessionAsync(session.SessionId));
await using var context = await factory.CreateDbContextAsync();
Assert.Empty(await context.AdminOidcLinks.ToListAsync());
Assert.NotNull((await context.SecretReferences.SingleAsync()).RevokedAt);
}
finally { root.Delete(true); }
}
private sealed class Factory(DbContextOptions<AllstarrDbContext> options) : IDbContextFactory<AllstarrDbContext>
{
public AllstarrDbContext CreateDbContext() => new(options);
}
private sealed class Clients(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, false);
}
private sealed class NativeHandler(string backend) : HttpMessageHandler
{
public bool Accept { get; set; } = true;
public bool Admin { get; set; }
public string UserId { get; set; } = "alice";
public int Calls { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
string json;
if (backend == "jellyfin")
{
Assert.Equal("/Users/Me", request.RequestUri!.AbsolutePath);
Assert.Contains("private-fixture-credential", request.Headers.GetValues("Authorization").Single());
Assert.False(request.Headers.Contains("X-Emby-Token"));
json = JsonSerializer.Serialize(new { Id = UserId, Name = "Alice", Policy = new { IsAdministrator = Admin, IsDisabled = !Accept } });
}
else
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("", request.RequestUri!.Query);
Assert.Contains("p=private-fixture-credential", await request.Content!.ReadAsStringAsync(cancellationToken));
json = JsonSerializer.Serialize(new Dictionary<string, object>
{
["subsonic-response"] = new { status = Accept ? "ok" : "failed", user = new { username = UserId, adminRole = Admin } }
});
}
return new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
}
}
}
+216
View File
@@ -0,0 +1,216 @@
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using allstarr.Middleware;
using allstarr.Services.Admin;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace allstarr.Tests;
public sealed class AdminOidcTests
{
[Theory]
[InlineData("https://admin.test/callback?code=private-code&state=private-state")]
[InlineData("?code=private-code&state=private-state")]
[InlineData("nonce=private-nonce")]
[InlineData("code_verifier=private-verifier")]
[InlineData("client_secret=private-client-secret")]
public void CallbackSecretsAreRedacted(string value)
{
var sanitized = allstarr.Core.Operations.SafeOperationalText.Sanitize(value);
Assert.DoesNotContain("private-", sanitized);
}
[Theory]
[InlineData("http://idp.test", "https://admin.test/allstarr")]
[InlineData("https://idp.test", "https://admin.test/wrong")]
[InlineData("https://idp.test", "https://admin.test/allstarr?redirect=elsewhere")]
[InlineData("https://idp.test", "https://user@admin.test/allstarr")]
public void InvalidConfigurationFailsClosed(string authority, string publicUrl)
{
var options = new AdminOidcOptions
{
Enabled = true,
Authority = authority,
PublicUrl = publicUrl,
ClientId = "app",
ClientSecret = "fixture"
};
Assert.Throws<InvalidOperationException>(() => options.Validate("/allstarr"));
}
[Fact]
public void DisabledOidcDoesNotRequireConfiguration() => new AdminOidcOptions().Validate("");
[Fact]
public void IdentityUsesIssuerSubjectAndClient_NotEmailOrRoles()
{
static ClaimsPrincipal Identity(string issuer, string subject, string email) => new(new ClaimsIdentity(
[new("iss", issuer), new("sub", subject), new("email", email), new(ClaimTypes.Role, "admin")], "oidc"));
var key = AdminOidcLinks.IdentityKey(Identity("one", "alice", "same@test"), "app");
Assert.Equal(key, AdminOidcLinks.IdentityKey(Identity("one", "alice", "changed@test"), "app"));
Assert.NotEqual(key, AdminOidcLinks.IdentityKey(Identity("two", "alice", "same@test"), "app"));
Assert.NotEqual(key, AdminOidcLinks.IdentityKey(Identity("one", "bob", "same@test"), "app"));
Assert.NotEqual(key, AdminOidcLinks.IdentityKey(Identity("one", "alice", "same@test"), "other-app"));
Assert.Null(AdminOidcLinks.IdentityKey(new ClaimsPrincipal(new ClaimsIdentity([new("iss", "one"), new("sub", "alice")])), "app"));
}
[Theory]
[InlineData("", "none")]
[InlineData("/allstarr", "none")]
[InlineData("/allstarr", "signature")]
[InlineData("/allstarr", "issuer")]
[InlineData("/allstarr", "audience")]
[InlineData("/allstarr", "nonce")]
[InlineData("/allstarr", "state")]
[InlineData("/allstarr", "cookie")]
public async Task AuthorizationCodeFlowVerifiesTokenAndPreservesPrefix(string prefix, string failure)
{
using var signingRsa = RSA.Create(2048);
using var wrongRsa = RSA.Create(2048);
var key = new RsaSecurityKey(signingRsa) { KeyId = "fixture" };
var tokenKey = failure == "signature" ? new RsaSecurityKey(wrongRsa) { KeyId = "fixture" } : key;
string? nonce = null;
var backchannel = new TokenHandler(() => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = failure == "issuer" ? "https://wrong-issuer.test" : "https://idp.test",
Audience = failure == "audience" ? "other-application" : "allstarr-test",
Expires = DateTime.UtcNow.AddMinutes(5),
IssuedAt = DateTime.UtcNow,
Claims = new Dictionary<string, object>
{
["sub"] = "subject-1",
["nonce"] = failure == "nonce" ? "wrong-nonce" : nonce!,
["email"] = "not-an-identity@test",
["role"] = "admin"
},
SigningCredentials = new SigningCredentials(tokenKey, SecurityAlgorithms.RsaSha256)
}));
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Admin:BasePath"] = prefix,
["Admin:Oidc:Enabled"] = "true",
["Admin:Oidc:Authority"] = "https://idp.test",
["Admin:Oidc:PublicUrl"] = "https://admin.test" + prefix,
["Admin:Oidc:ClientId"] = "allstarr-test",
["Admin:Oidc:ClientSecret"] = "fixture-secret"
}).Build();
using var host = await new HostBuilder().ConfigureWebHost(web => web.UseTestServer().ConfigureServices(services =>
{
services.AddSingleton<IConfiguration>(config);
services.AddSingleton<AdminBasePath>();
services.AddAdminOidc(config);
services.Configure<OpenIdConnectOptions>(AdminOidcOptions.Scheme, options =>
{
options.Configuration = new OpenIdConnectConfiguration
{
Issuer = "https://idp.test",
AuthorizationEndpoint = "https://idp.test/authorize",
TokenEndpoint = "https://idp.test/token"
};
options.Configuration.SigningKeys.Add(key);
options.Backchannel = new HttpClient(backchannel);
});
}).Configure(app =>
{
app.Use((context, next) =>
{
context.Connection.LocalPort = context.Request.Headers.ContainsKey("X-Test-Native-Port") ? 8080 : 5275;
return next(context);
});
app.UseMiddleware<AdminBasePathMiddleware>();
app.UseAuthentication();
app.Run(async context =>
{
if (context.Connection.LocalPort == 8080) await context.Response.WriteAsync("native");
else if (context.Request.Path == "/login")
await context.ChallengeAsync(AdminOidcOptions.Scheme, new AuthenticationProperties { RedirectUri = prefix + "/complete" });
else
{
var pending = await context.AuthenticateAsync(AdminOidcOptions.PendingScheme);
await context.Response.WriteAsJsonAsync(new
{
authenticated = pending.Succeeded,
claims = pending.Principal?.Claims.Select(claim => claim.Type).ToArray(),
tokens = pending.Properties?.GetTokens().Count(),
expires = pending.Properties?.ExpiresUtc
});
}
});
})).StartAsync();
using var client = host.GetTestClient();
client.BaseAddress = new Uri("https://untrusted-host.test");
using var nativeRequest = new HttpRequestMessage(HttpMethod.Get, AdminOidcOptions.CallbackPath + "?code=ignored&state=ignored");
nativeRequest.Headers.Add("X-Test-Native-Port", "true");
using var nativeResponse = await client.SendAsync(nativeRequest);
Assert.Equal(HttpStatusCode.OK, nativeResponse.StatusCode);
Assert.Equal("native", await nativeResponse.Content.ReadAsStringAsync());
Assert.False(nativeResponse.Headers.Contains("Set-Cookie"));
using var challenge = await client.GetAsync(prefix + "/login");
Assert.Equal(HttpStatusCode.Redirect, challenge.StatusCode);
var parameters = QueryHelpers.ParseQuery(challenge.Headers.Location!.Query);
Assert.Equal("code", parameters["response_type"]);
Assert.Equal("S256", parameters["code_challenge_method"]);
Assert.Equal("https://admin.test" + prefix + AdminOidcOptions.CallbackPath, parameters["redirect_uri"]);
nonce = parameters["nonce"];
var cookies = challenge.Headers.GetValues("Set-Cookie").ToArray();
Assert.All(cookies, cookie => Assert.Contains("path=" + prefix + AdminOidcOptions.CallbackPath, cookie));
using var callback = new HttpRequestMessage(HttpMethod.Get,
QueryHelpers.AddQueryString(prefix + AdminOidcOptions.CallbackPath, new Dictionary<string, string?>
{
["code"] = "fixture-code",
["state"] = failure == "state" ? "invalid-state" : parameters["state"]
}));
if (failure != "cookie") callback.Headers.Add("Cookie", string.Join("; ", cookies.Select(cookie => cookie.Split(';')[0])));
using var response = await client.SendAsync(callback);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
if (failure != "none")
{
Assert.EndsWith("/?oidc=failed", response.Headers.Location!.ToString());
Assert.DoesNotContain(response.Headers.TryGetValues("Set-Cookie", out var failedCookies) ? failedCookies : [],
cookie => cookie.StartsWith("allstarr_oidc_pending="));
return;
}
Assert.Equal(prefix + "/complete", response.Headers.Location!.ToString());
var pendingCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), cookie => cookie.StartsWith("allstarr_oidc_pending="));
Assert.Contains("secure", pendingCookie);
Assert.Contains("httponly", pendingCookie);
using var complete = new HttpRequestMessage(HttpMethod.Get, prefix + "/complete");
complete.Headers.Add("Cookie", pendingCookie.Split(';')[0]);
using var completeResponse = await client.SendAsync(complete);
using var result = JsonDocument.Parse(await completeResponse.Content.ReadAsStringAsync());
Assert.True(result.RootElement.GetProperty("authenticated").GetBoolean());
Assert.Equal(new[] { "iss", "sub" }, result.RootElement.GetProperty("claims").EnumerateArray().Select(item => item.GetString()));
Assert.Equal(0, result.RootElement.GetProperty("tokens").GetInt32());
Assert.InRange(result.RootElement.GetProperty("expires").GetDateTimeOffset(), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(5));
Assert.Contains("code_verifier=", backchannel.RequestBody);
Assert.Equal("https://admin.test" + prefix + AdminOidcOptions.CallbackPath, QueryHelpers.ParseQuery(backchannel.RequestBody)["redirect_uri"]);
}
private sealed class TokenHandler(Func<string> token) : HttpMessageHandler
{
public string RequestBody { get; private set; } = "";
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestBody = await request.Content!.ReadAsStringAsync(cancellationToken);
return new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(new { access_token = "unused", token_type = "Bearer", id_token = token() }), Encoding.UTF8, "application/json")
};
}
}
}
@@ -0,0 +1,37 @@
using allstarr.Services.Admin;
using Microsoft.AspNetCore.Http;
namespace allstarr.Tests;
public sealed class AdminSessionCookiesTests
{
[Theory]
[InlineData("", "/")]
[InlineData("/allstarr", "/allstarr")]
public void SessionCookiesStayInAdminScope(string prefix, string cookiePath)
{
var context = new DefaultHttpContext();
context.Request.PathBase = prefix;
context.Request.Scheme = "https";
AdminSessionCookies.Write(context, "fixture", DateTime.UtcNow.AddHours(1));
var cookie = Assert.Single(context.Response.Headers.SetCookie)!;
Assert.Contains("path=" + cookiePath + ";", cookie);
Assert.Contains("secure", cookie);
Assert.Contains("httponly", cookie);
Assert.Contains("samesite=strict", cookie);
context.Response.Headers.Clear();
AdminSessionCookies.Delete(context);
Assert.Equal(3, context.Response.Headers.SetCookie.Count);
Assert.All(context.Response.Headers.SetCookie, value => Assert.Contains("path=" + cookiePath, value));
}
[Fact]
public void UntrustedForwardedHeaderDoesNotControlCookieSecurity()
{
var context = new DefaultHttpContext();
context.Request.Scheme = "http";
context.Request.Headers["X-Forwarded-Proto"] = "https";
AdminSessionCookies.Write(context, "fixture", DateTime.UtcNow.AddHours(1));
Assert.DoesNotContain("; secure", Assert.Single(context.Response.Headers.SetCookie)!);
}
}
@@ -1,5 +1,9 @@
using System.Security.Cryptography;
using System.Text;
using allstarr.Middleware;
using allstarr.Services.Admin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Moq;
namespace allstarr.Tests;
@@ -30,6 +34,64 @@ public class AdminStaticFilesMiddlewareTests
}
}
[Fact]
public void PrefixTransformationDoesNotAuthorizePreviouslyBlockedInlineScripts()
{
const string html = "<meta http-equiv=\"content-security-policy\" content=\"script-src 'self'\"><script>import(\"/_app/app.js\")</script>";
var transformed = AdminStaticFilesMiddleware.TransformIndexHtml(html, "/admin");
Assert.DoesNotContain("sha256-", transformed);
Assert.Contains("import(\"/admin/_app/app.js\")", transformed);
}
[Fact]
public async Task InvokeAsync_ConfiguredPrefix_TransformsIndexAssetsAndCsp()
{
const string bootstrap = "import(\"/_app/immutable/entry/app.js\")";
var oldHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(bootstrap)));
var html = $"""
<meta name="allstarr-base-path" content="" />
<meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self' 'sha256-{oldHash}';">
<script>{bootstrap}</script>
<link rel="modulepreload" href="/_app/immutable/entry/start.js">
<link rel="icon" href="/favicon.svg">
""";
var webRoot = CreateTempWebRoot();
await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html);
try
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AdminBasePath.ConfigurationKey] = "/admin/",
})
.Build();
var basePath = new AdminBasePath(configuration);
var middleware = CreateMiddleware(webRoot, out var nextInvoked, basePath);
var context = CreateContext(localPort: 5275, path: "/");
await middleware.InvokeAsync(context);
context.Response.Body.Position = 0;
using var reader = new StreamReader(context.Response.Body, Encoding.UTF8);
var transformed = await reader.ReadToEndAsync();
var newHash = Convert.ToBase64String(SHA256.HashData(
Encoding.UTF8.GetBytes("import(\"/admin/_app/immutable/entry/app.js\")")));
Assert.False(nextInvoked());
Assert.Contains("name=\"allstarr-base-path\" content=\"/admin\"", transformed);
Assert.Contains("href=\"/admin/_app/immutable/entry/start.js\"", transformed);
Assert.Contains("href=\"/admin/favicon.svg\"", transformed);
Assert.Contains($"'sha256-{newHash}'", transformed);
Assert.DoesNotContain($"'sha256-{oldHash}'", transformed);
Assert.Equal("no-store", context.Response.Headers.CacheControl);
}
finally
{
DeleteTempWebRoot(webRoot);
}
}
[Fact]
public async Task InvokeAsync_AdminPathTraversalAttempt_ReturnsNotFound()
{
@@ -161,7 +223,8 @@ public class AdminStaticFilesMiddlewareTests
private static AdminStaticFilesMiddleware CreateMiddleware(
string webRootPath,
out Func<bool> nextInvoked)
out Func<bool> nextInvoked,
AdminBasePath? basePath = null)
{
var invoked = false;
nextInvoked = () => invoked;
@@ -176,7 +239,8 @@ public class AdminStaticFilesMiddlewareTests
context.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
},
environment.Object);
environment.Object,
basePath);
}
private static DefaultHttpContext CreateContext(int localPort, string path)
@@ -62,8 +62,15 @@ public sealed class ComposeContractTests
.ToArray();
Assert.Equal(
[
"ADMIN_BASE_PATH",
"ADMIN_BIND_ADDRESS",
"ADMIN_BIND_ANY_IP",
"ADMIN_OIDC_AUTHORITY",
"ADMIN_OIDC_CLIENT_ID",
"ADMIN_OIDC_CLIENT_SECRET",
"ADMIN_OIDC_DISPLAY_NAME",
"ADMIN_OIDC_ENABLED",
"ADMIN_OIDC_PUBLIC_URL",
"ADMIN_PORT",
"ADMIN_TRUSTED_SUBNETS",
"ALLSTARR_IMAGE",
@@ -34,6 +34,21 @@ public sealed class RuntimeEnvConfigurationTests : IDisposable
Assert.Equal("development", mapping.Value);
}
[Theory]
[InlineData("ADMIN_BASE_PATH", "Admin:BasePath")]
[InlineData("ADMIN_OIDC_ENABLED", "Admin:Oidc:Enabled")]
[InlineData("ADMIN_OIDC_AUTHORITY", "Admin:Oidc:Authority")]
[InlineData("ADMIN_OIDC_CLIENT_ID", "Admin:Oidc:ClientId")]
[InlineData("ADMIN_OIDC_CLIENT_SECRET", "Admin:Oidc:ClientSecret")]
[InlineData("ADMIN_OIDC_PUBLIC_URL", "Admin:Oidc:PublicUrl")]
[InlineData("ADMIN_OIDC_DISPLAY_NAME", "Admin:Oidc:DisplayName")]
public void MapEnvVarToConfiguration_MapsAdminBootstrap(string environmentKey, string configurationKey)
{
var mapping = Assert.Single(RuntimeEnvConfiguration.MapEnvVarToConfiguration(environmentKey, "value"));
Assert.Equal(configurationKey, mapping.Key);
Assert.Equal("value", mapping.Value);
}
[Theory]
[InlineData("MUSICBRAINZ_SOURCE_ID", "MusicBrainz:SourceId")]
[InlineData("MUSICBRAINZ_BASE_URL", "MusicBrainz:BaseUrl")]
@@ -14,6 +14,6 @@ public sealed class MigrationModelSnapshotTests
using var context = new AllstarrDbContext(options);
Assert.False(context.Database.HasPendingModelChanges());
Assert.Equal("20260922000030_ProjectNativeTrackAliases", context.Database.GetMigrations().Last());
Assert.Equal("20260927214131_AddAdminOidcLinks", context.Database.GetMigrations().Last());
}
}
+52 -63
View File
@@ -8,6 +8,8 @@ using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Core.Identity;
using allstarr.Core.Configuration;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
namespace allstarr.Controllers;
@@ -26,6 +28,10 @@ public sealed class AdminAuthController : ControllerBase
private readonly ProviderAccountManagementMode _providerAccountManagementMode;
private readonly IMediaAssetResolver _mediaAssets;
private readonly ReleaseComposition _releaseComposition;
private readonly AdminOidcOptions? _oidcOptions;
private readonly AdminOidcLinks? _oidcLinks;
private readonly IAntiforgery? _antiforgery;
private string? _pendingOidcKey;
public AdminAuthController(
IOptions<JellyfinSettings> jellyfinSettings,
@@ -37,7 +43,10 @@ public sealed class AdminAuthController : ControllerBase
IMediaAssetResolver mediaAssets,
BackendIdentityResolver? identityResolver = null,
ProviderAccountManagementOptions? providerAccountManagementOptions = null,
ReleaseComposition? releaseComposition = null)
ReleaseComposition? releaseComposition = null,
AdminOidcOptions? oidcOptions = null,
AdminOidcLinks? oidcLinks = null,
IAntiforgery? antiforgery = null)
{
_jellyfinSettings = jellyfinSettings.Value;
_subsonicSettings = subsonicSettings.Value;
@@ -55,11 +64,25 @@ public sealed class AdminAuthController : ControllerBase
_providerAccountManagementMode = (providerAccountManagementOptions ?? new())
.ParseManagementMode();
_releaseComposition = releaseComposition ?? ReleaseComposition.Core;
_oidcOptions = oidcOptions;
_oidcLinks = oidcLinks;
_antiforgery = antiforgery;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (request.LinkOidc)
{
if (!Request.IsHttps) return BadRequest(new { error = "SSO linking requires HTTPS." });
if (_oidcOptions?.Enabled != true || _oidcLinks == null || _antiforgery == null)
return BadRequest(new { error = "SSO is not enabled." });
if (!await _antiforgery.IsRequestValidAsync(HttpContext))
return BadRequest(new { error = "Reload the page before linking SSO." });
var pending = await HttpContext.AuthenticateAsync(AdminOidcOptions.PendingScheme);
_pendingOidcKey = pending.Principal == null ? null : AdminOidcLinks.IdentityKey(pending.Principal, _oidcOptions.ClientId);
if (_pendingOidcKey == null) return Unauthorized(new { error = "Sign in through SSO again before linking." });
}
if (_backendType == BackendType.Subsonic)
{
return await LoginWithSubsonicAsync(request);
@@ -150,7 +173,7 @@ public sealed class AdminAuthController : ControllerBase
isAdministrator,
accessToken,
serverId,
request.RememberMe);
request);
}
catch (JsonException)
{
@@ -185,9 +208,7 @@ public sealed class AdminAuthController : ControllerBase
});
}
// Re-issue the canonical root-scoped cookie while validating the session.
// Older Allstarr builds could leave a more narrowly scoped cookie behind,
// causing /auth/me to succeed while sibling admin APIs received a stale ID.
// Renew the cookie at the configured admin path, shared by all admin APIs.
SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
return Ok(AuthenticatedSessionResponse(session));
@@ -276,32 +297,14 @@ public sealed class AdminAuthController : ControllerBase
}
DeleteSessionCookies();
if (_oidcOptions?.Enabled == true) await HttpContext.SignOutAsync(AdminOidcOptions.PendingScheme);
return Ok(new { success = true });
}
private void DeleteSessionCookies()
{
Response.Cookies.Delete(AdminAuthSessionService.SessionCookieName, new CookieOptions { Path = "/" });
Response.Cookies.Delete(AdminAuthSessionService.LegacySessionCookieName, new CookieOptions { Path = "/" });
Response.Cookies.Delete(AdminAuthSessionService.LegacySessionCookieName, new CookieOptions { Path = "/api/admin/auth" });
}
private void DeleteSessionCookies() => AdminSessionCookies.Delete(HttpContext);
private void SetSessionCookie(string sessionId, DateTime expiresAtUtc)
{
var secure = Request.IsHttps ||
string.Equals(Request.Headers["X-Forwarded-Proto"], "https",
StringComparison.OrdinalIgnoreCase);
Response.Cookies.Append(AdminAuthSessionService.SessionCookieName, sessionId, new CookieOptions
{
HttpOnly = true,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/",
IsEssential = true,
Expires = expiresAtUtc
});
}
=> AdminSessionCookies.Write(HttpContext, sessionId, expiresAtUtc);
private async Task<IActionResult> LoginWithSubsonicAsync(LoginRequest request)
{
@@ -350,19 +353,19 @@ public sealed class AdminAuthController : ControllerBase
using var document = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(HttpContext.RequestAborted),
cancellationToken: HttpContext.RequestAborted);
if (!TryReadSubsonicIdentity(document.RootElement, username, out var identity))
if (!AdminBackendIdentity.TryReadSubsonic(document.RootElement, username, out var identity, allowMissingUserName: true))
{
return Unauthorized(new { error = "Invalid Subsonic credentials" });
}
return await CompleteLoginAsync(
BackendType.Subsonic,
identity.UserName,
identity.UserName,
identity.UserId,
identity.Name,
identity.IsAdministrator,
string.Empty,
null,
request.RememberMe);
request);
}
catch (JsonException)
{
@@ -381,35 +384,6 @@ public sealed class AdminAuthController : ControllerBase
}
}
private static bool TryReadSubsonicIdentity(
JsonElement root,
string requestedUserName,
out SubsonicIdentity identity)
{
identity = default;
if (!root.TryGetProperty("subsonic-response", out var envelope) ||
!envelope.TryGetProperty("status", out var status) ||
!string.Equals(status.GetString(), "ok", StringComparison.OrdinalIgnoreCase) ||
!envelope.TryGetProperty("user", out var user))
{
return false;
}
var returnedUserName = user.TryGetProperty("username", out var username)
? username.GetString()
: requestedUserName;
if (string.IsNullOrWhiteSpace(returnedUserName) ||
!returnedUserName.Equals(requestedUserName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var isAdministrator = user.TryGetProperty("adminRole", out var adminRole) &&
adminRole.ValueKind == JsonValueKind.True;
identity = new SubsonicIdentity(returnedUserName, isAdministrator);
return true;
}
private async Task<IActionResult> CompleteLoginAsync(
BackendType backend,
string userId,
@@ -417,7 +391,7 @@ public sealed class AdminAuthController : ControllerBase
bool isAdministrator,
string accessToken,
string? serverId,
bool isPersistent)
LoginRequest request)
{
var backendName = backend.ToString();
var principal = _identityResolver == null
@@ -425,13 +399,29 @@ public sealed class AdminAuthController : ControllerBase
: await _identityResolver.ResolveAsync(
new BackendIdentityDescriptor(backendName, userId, userName, isAdministrator),
HttpContext.RequestAborted);
if (_pendingOidcKey != null)
{
if (principal == null) return StatusCode(503, new { error = "Native account identity is unavailable. SSO was not linked." });
try
{
await _oidcLinks!.LinkAsync(_pendingOidcKey, principal,
new(backendName.ToLowerInvariant(),
(backend == BackendType.Jellyfin ? _jellyfinSettings.Url : _subsonicSettings.Url)!.TrimEnd('/'),
userId, backend == BackendType.Jellyfin ? accessToken : request.Password!), HttpContext.RequestAborted);
}
catch (Exception exception) when (exception is UnauthorizedAccessException or Microsoft.EntityFrameworkCore.DbUpdateException)
{
return Conflict(new { error = "This SSO identity or media account is already linked. Disconnect its existing link first." });
}
await HttpContext.SignOutAsync(AdminOidcOptions.PendingScheme);
}
var session = await _sessionService.CreateSessionAsync(
userId,
userName,
isAdministrator,
accessToken,
serverId,
isPersistent,
request.RememberMe,
backendName,
principal?.TenantId,
principal?.UserId,
@@ -473,13 +463,12 @@ public sealed class AdminAuthController : ControllerBase
intelligence = _releaseComposition.IntelligenceEnabled
};
private readonly record struct SubsonicIdentity(string UserName, bool IsAdministrator);
public sealed class LoginRequest
{
public string? Username { get; set; }
public string? Password { get; set; }
public bool RememberMe { get; set; }
public bool LinkOidc { get; set; }
}
private sealed class JellyfinAuthenticateRequest
@@ -0,0 +1,94 @@
using allstarr.Filters;
using allstarr.Services.Admin;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
[ApiController]
[Route("api/admin/auth/oidc")]
[ServiceFilter(typeof(AdminPortFilter))]
public sealed class AdminOidcController(
AdminOidcOptions options,
AdminOidcLinks links,
AdminAuthSessionService sessions,
IAntiforgery antiforgery,
ILogger<AdminOidcController> logger) : ControllerBase
{
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
{
Response.Headers.CacheControl = "no-store";
if (!options.Enabled) return Ok(new { enabled = false });
var pending = await HttpContext.AuthenticateAsync(AdminOidcOptions.PendingScheme);
var session = await sessions.GetValidSessionAsync(Request, cancellationToken);
return Ok(new
{
enabled = true,
displayName = options.DisplayName,
loginUrl = Request.PathBase + "/api/admin/auth/oidc/login",
linkPending = pending.Succeeded,
linked = session != null && await links.IsLinkedAsync(session, cancellationToken),
csrfToken = antiforgery.GetAndStoreTokens(HttpContext).RequestToken
});
}
[HttpGet("login")]
public async Task<IActionResult> Login()
{
if (!options.Enabled) return NotFound();
if (!Request.IsHttps) return BadRequest(new { error = "SSO requires HTTPS. Check the proxy's trusted forwarded-protocol configuration." });
await HttpContext.SignOutAsync(AdminOidcOptions.PendingScheme);
return Challenge(new AuthenticationProperties
{
RedirectUri = options.PublicUrl + "/api/admin/auth/oidc/complete",
IsPersistent = false
}, AdminOidcOptions.Scheme);
}
[HttpGet("complete")]
public async Task<IActionResult> Complete(CancellationToken cancellationToken)
{
if (!options.Enabled) return NotFound();
if (!Request.IsHttps) return BadRequest(new { error = "SSO requires HTTPS." });
var pending = await HttpContext.AuthenticateAsync(AdminOidcOptions.PendingScheme);
var key = pending.Principal == null ? null : AdminOidcLinks.IdentityKey(pending.Principal, options.ClientId);
if (key == null) return Redirect(options.PublicUrl + "/?oidc=failed");
try
{
var session = await links.SignInAsync(key, cancellationToken);
if (session == null) return Redirect(options.PublicUrl + "/?oidc=link");
AdminSessionCookies.Write(HttpContext, session);
await HttpContext.SignOutAsync(AdminOidcOptions.PendingScheme);
return Redirect(options.PublicUrl + "/");
}
catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
logger.LogWarning("OIDC backend validation failed ({ExceptionType})", exception.GetType().Name);
return Redirect(options.PublicUrl + "/?oidc=failed");
}
}
[HttpDelete("link")]
public async Task<IActionResult> Unlink(CancellationToken cancellationToken)
{
if (!options.Enabled) return NotFound();
var session = await sessions.GetValidSessionAsync(Request, cancellationToken);
if (session == null) return Unauthorized();
if (!await antiforgery.IsRequestValidAsync(HttpContext)) return BadRequest(new { error = "Reload the page before disconnecting SSO." });
await links.UnlinkAsync(session, cancellationToken);
await HttpContext.SignOutAsync(AdminOidcOptions.PendingScheme);
if (session.OidcSecretReferenceId != null) AdminSessionCookies.Delete(HttpContext);
return Ok(new { success = true });
}
[HttpDelete("pending")]
public async Task<IActionResult> Cancel()
{
if (!options.Enabled) return NotFound();
if (!await antiforgery.IsRequestValidAsync(HttpContext)) return BadRequest();
await HttpContext.SignOutAsync(AdminOidcOptions.PendingScheme);
return Ok(new { success = true });
}
}
@@ -147,6 +147,8 @@ public static class LegacyEnvParser
"ADMIN_BIND_ADDRESS", "ADMIN_PORT", "ADMIN__ENABLE_ENV_EXPORT",
"CORS__ALLOWED_ORIGINS", "CORS__ALLOWED_METHODS", "CORS__ALLOWED_HEADERS", "CORS__ALLOW_CREDENTIALS",
"BACKEND_TYPE", "Backend__Type", "ADMIN_BIND_ANY_IP", "ADMIN_TRUSTED_SUBNETS", "ADMIN_ENABLE_ENV_EXPORT",
"ADMIN_BASE_PATH", "ADMIN_OIDC_ENABLED", "ADMIN_OIDC_AUTHORITY", "ADMIN_OIDC_CLIENT_ID",
"ADMIN_OIDC_CLIENT_SECRET", "ADMIN_OIDC_PUBLIC_URL", "ADMIN_OIDC_DISPLAY_NAME",
"CORS_ALLOWED_ORIGINS", "CORS_ALLOWED_METHODS", "CORS_ALLOWED_HEADERS", "CORS_ALLOW_CREDENTIALS",
"SUBSONIC_URL", "JELLYFIN_URL", "JELLYFIN_API_KEY", "JELLYFIN_USER_ID", "JELLYFIN_CLIENT_USERNAME",
"JELLYFIN_LIBRARY_ID", "ALLSTARR_STORAGE_PROVIDER", "ALLSTARR_STORAGE_CONNECTION_STRING",
@@ -185,7 +185,7 @@ internal sealed partial class RedactingConsoleLogger(
}
[GeneratedRegex(
"(^key$)|(^error$)|(^message$)|([.]message$)|token|password|secret|cookie|authorization|credential|api.?key|client.?id|private.?key|cachekey|connectionstring|dsn|arl|(^body$)|([.]body$)|(^xml$)|([.]xml$)|(^json$)|([.]json$)|header|commandtext|parameters|sessionid|playsessionid|(^response$)|([.]response$)|(^content$)|([.]content$)|payload|exception|preview|reasonphrase",
"(^key$)|(^error$)|(^message$)|([.]message$)|token|password|secret|cookie|authorization|credential|api.?key|client.?id|private.?key|cachekey|connectionstring|dsn|arl|(^body$)|([.]body$)|(^xml$)|([.]xml$)|(^json$)|([.]json$)|header|commandtext|parameters|sessionid|playsessionid|(^response$)|([.]response$)|(^content$)|([.]content$)|payload|exception|preview|reasonphrase|querystring|^code$|^state$|nonce|code_verifier",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SensitiveFieldName();
}
@@ -46,11 +46,11 @@ public static partial class SafeOperationalText
[GeneratedRegex("https?://[^\\s,;\\\"'<>]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex UrlPattern();
[GeneratedRegex("token|password|secret|cookie|authorization|api.?key|client.?id|private.?key|arl|signature|sig|expires", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
[GeneratedRegex("token|password|secret|cookie|authorization|api.?key|client.?id|private.?key|arl|signature|sig|expires|^code$|^state$|nonce|code_verifier", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SensitiveQueryKey();
[GeneratedRegex(
@"\b(token|password|secret|cookie|authorization|api[_-]?key|arl)\s*[=:]\s*[^\s,;]+",
@"\b(token|password|secret|cookie|authorization|api[_-]?key|arl|code|state|nonce|code_verifier|client_secret)\s*[=:]\s*[^\s,;]+",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex CredentialPattern();
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
namespace allstarr.Core.Storage;
public sealed class AdminOidcLinkRecord
{
public const string SecretPurpose = "admin-oidc-backend";
public required string Id { get; set; }
public Guid BackendIdentityId { get; set; }
public Guid SecretReferenceId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public sealed partial class AllstarrDbContext
{
public DbSet<AdminOidcLinkRecord> AdminOidcLinks => Set<AdminOidcLinkRecord>();
private static void ConfigureAdminOidcLinks(ModelBuilder builder)
{
builder.Entity<AdminOidcLinkRecord>(entity =>
{
entity.ToTable("admin_oidc_links");
entity.HasKey(item => item.Id);
entity.Property(item => item.Id).HasMaxLength(64);
entity.HasIndex(item => item.BackendIdentityId).IsUnique();
entity.HasOne<BackendIdentityRecord>().WithMany().HasForeignKey(item => item.BackendIdentityId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<SecretReferenceRecord>().WithMany().HasForeignKey(item => item.SecretReferenceId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
@@ -78,6 +78,7 @@ public sealed partial class AllstarrDbContext(DbContextOptions<AllstarrDbContext
ConfigureTenant(modelBuilder);
ConfigureOnboarding(modelBuilder);
ConfigureAdminAuthSessions(modelBuilder);
ConfigureAdminOidcLinks(modelBuilder);
ConfigureProviderAccounts(modelBuilder);
ConfigureSecrets(modelBuilder);
ConfigureJobs(modelBuilder);
@@ -169,8 +169,10 @@ public sealed class DurableStateTransferService
await WriteEntryAsync(archive, "users.json", await context.Users.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "backend-identities.json", await context.BackendIdentities.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "onboarding-states.json", await context.OnboardingStates.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "secret-references.json", await context.SecretReferences.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "secret-versions.json", await context.SecretVersions.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
var portableSecrets = context.SecretReferences.AsNoTracking().Where(item => item.Purpose != AdminOidcLinkRecord.SecretPurpose);
await WriteEntryAsync(archive, "secret-references.json", await portableSecrets.ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "secret-versions.json", await context.SecretVersions.AsNoTracking()
.Where(item => portableSecrets.Any(secret => secret.Id == item.SecretReferenceId)).ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "provider-accounts.json", await context.ProviderAccounts.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "canonical-recordings.json", await context.CanonicalRecordings.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
await WriteEntryAsync(archive, "provider-track-identities.json", await context.ProviderTrackIdentities.AsNoTracking().ToListAsync(cancellationToken), cancellationToken);
@@ -0,0 +1,6015 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using allstarr.Core.Storage;
#nullable disable
namespace allstarr.Core.Storage.Migrations
{
[DbContext(typeof(AllstarrDbContext))]
[Migration("20260927214131_AddAdminOidcLinks")]
partial class AddAdminOidcLinks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("allstarr.Core.Downloads.DownloadedSongMappingEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Album")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("AudioQuality")
.HasColumnType("integer");
b.Property<long>("DownloadedAt")
.HasColumnType("bigint");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("LocalPath")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("ScopeKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("ScopeKey", "ProviderId", "ExternalId")
.IsUnique()
.HasDatabaseName("IX_downloaded_song_mapping_identity");
b.ToTable("downloaded_song_mappings", (string)null);
});
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadArtifactEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int?>("BitDepth")
.HasColumnType("integer");
b.Property<int?>("Bitrate")
.HasColumnType("integer");
b.Property<int?>("Channels")
.HasColumnType("integer");
b.Property<string>("Codec")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Container")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ContentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("DurableJobId")
.HasColumnType("uuid");
b.Property<long>("Length")
.HasColumnType("bigint");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("ManagedFileId")
.HasColumnType("uuid");
b.Property<string>("MimeType")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<long?>("PlacedAt")
.HasColumnType("bigint");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderArtifactId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<int?>("SampleRate")
.HasColumnType("integer");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("VerifiedAt")
.HasColumnType("bigint");
b.Property<string>("WorkspaceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceRecordId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ManagedFileId");
b.HasIndex("WorkspaceRecordId", "ProviderArtifactId")
.IsUnique()
.HasDatabaseName("IX_download_artifact_identity");
b.HasIndex("TenantId", "DurableJobId", "ProviderId")
.IsUnique()
.HasDatabaseName("IX_download_artifact_job_provider");
b.ToTable("provider_download_artifacts", null, t =>
{
t.HasCheckConstraint("CK_download_artifact_length", "\"Length\" > 0");
t.HasCheckConstraint("CK_download_artifact_sha", "length(\"ContentSha256\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadWorkspaceEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("DurableJobId")
.HasColumnType("uuid");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("WorkspaceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("DurableJobId");
b.HasIndex("WorkspaceId")
.IsUnique();
b.HasIndex("ProviderAccountId", "ProviderId");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("TenantId", "DurableJobId", "ProviderId", "ProviderAccountId", "IdempotencyKey")
.IsUnique()
.HasDatabaseName("IX_download_workspace_idempotency");
b.ToTable("provider_download_workspaces", (string)null);
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionPolicyRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<bool?>("AddToVirtualLiked")
.HasColumnType("boolean");
b.Property<bool?>("AutoDownload")
.HasColumnType("boolean");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<bool?>("EnrichMetadata")
.HasColumnType("boolean");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<bool?>("MatchLocalLibrary")
.HasColumnType("boolean");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<bool?>("PlaceManagedFile")
.HasColumnType("boolean");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool?>("RefreshBackendLibrary")
.HasColumnType("boolean");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("TargetCredentialReferenceId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.Property<Guid>("UpdatedByUserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TargetCredentialReferenceId")
.HasDatabaseName("IX_favorite_policy_credential_reference");
b.HasIndex("TenantId", "UpdatedByUserId");
b.HasIndex("TenantId", "OwnerUserId", "Scope", "Protocol", "BackendInstanceId", "LibraryScopeId")
.IsUnique()
.HasDatabaseName("IX_favorite_policy_scope");
b.ToTable("favorite_action_policies", (string)null);
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ActionType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("AttemptCount")
.HasColumnType("integer");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("EventId")
.HasColumnType("uuid");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("LastErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LastErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<bool>("Reversible")
.HasColumnType("boolean");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("EventId", "ActionType")
.IsUnique()
.HasDatabaseName("IX_favorite_action_type");
b.HasIndex("EventId", "TenantId", "OwnerUserId")
.HasDatabaseName("IX_favorite_action_event");
b.HasIndex("TenantId", "OwnerUserId", "State")
.HasDatabaseName("IX_favorite_action_owner_state");
b.ToTable("favorite_actions", (string)null);
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteEventRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BackendPrincipalId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ItemId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("JobId")
.HasColumnType("uuid");
b.Property<string>("LastErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LastErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PolicySnapshotJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SourceRevision")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("TargetCredentialReferenceId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("EventKey")
.IsUnique()
.HasDatabaseName("IX_favorite_event_key");
b.HasIndex("JobId")
.HasDatabaseName("IX_favorite_event_job");
b.HasIndex("TargetCredentialReferenceId")
.HasDatabaseName("IX_favorite_event_credential_reference");
b.HasIndex("TenantId", "OwnerUserId", "CreatedAt")
.HasDatabaseName("IX_favorite_event_owner_created");
b.ToTable("favorite_events", (string)null);
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteStateRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsFavorite")
.HasColumnType("boolean");
b.Property<string>("ItemId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("LastEventId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("LastEventId", "TenantId", "OwnerUserId")
.HasDatabaseName("IX_favorite_state_event");
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "ItemId")
.IsUnique()
.HasDatabaseName("IX_favorite_state_owner_target");
b.ToTable("favorite_states", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetEntryRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ExplanationJson")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("GeneratedSetId")
.HasColumnType("uuid");
b.Property<string>("IdentityJson")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<double>("Score")
.HasColumnType("double precision");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TrackKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("GeneratedSetId", "Position")
.IsUnique();
b.HasIndex("GeneratedSetId", "TenantId", "OwnerUserId");
b.ToTable("generated_set_entries", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BackendPlaylistId")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("LastErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("MaterializationState")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long?>("MaterializedAt")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid?>("RunId")
.HasColumnType("uuid");
b.Property<Guid?>("ScheduleId")
.HasColumnType("uuid");
b.Property<Guid?>("TargetCredentialReferenceId")
.HasColumnType("uuid");
b.Property<string>("TargetRevision")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RunId")
.IsUnique();
b.HasIndex("ScheduleId")
.HasDatabaseName("IX_generated_set_schedule");
b.HasIndex("TargetCredentialReferenceId")
.HasDatabaseName("IX_generated_set_credential_reference");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("RunId", "TenantId", "OwnerUserId");
b.ToTable("generated_sets", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.IntelligencePolicyRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AllowedSignalTypesJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("EnabledProvidersJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("RetentionDays")
.HasColumnType("integer");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid?>("TargetCredentialReferenceId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TargetCredentialReferenceId")
.HasDatabaseName("IX_intelligence_policy_credential_reference");
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId")
.IsUnique()
.HasDatabaseName("IX_intelligence_policy_scope");
b.ToTable("intelligence_policies", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningEventRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Album")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AlbumArtist")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Artist")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<bool>("ChosenByUser")
.HasColumnType("boolean");
b.Property<string>("ClientClass")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("DeviceClass")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("DurationMilliseconds")
.HasColumnType("bigint");
b.Property<string>("ImportProvenance")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Isrc")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("LibraryTrackId")
.HasColumnType("uuid");
b.Property<long?>("ListenedAt")
.HasColumnType("bigint");
b.Property<long?>("MusicBrainzEnrichedAt")
.HasColumnType("bigint");
b.Property<double?>("MusicBrainzEnrichmentConfidence")
.HasColumnType("double precision");
b.Property<string>("MusicBrainzEnrichmentState")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("MusicBrainzFactsJson")
.HasColumnType("text");
b.Property<string>("MusicBrainzSourceRevision")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("OccurrenceKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<long?>("PositionTicks")
.HasColumnType("bigint");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ProviderTrackIdentityId")
.HasColumnType("uuid");
b.Property<string>("ProviderTrackReference")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("RecordingMusicBrainzId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long?>("StartedAt")
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int?>("TrackNumber")
.HasColumnType("integer");
b.Property<string>("TrackReference")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ProviderAccountId");
b.HasIndex("ProviderTrackIdentityId");
b.HasIndex("TenantId", "CanonicalRecordingId");
b.HasIndex("TenantId", "LibraryTrackId");
b.HasIndex("TenantId", "OwnerUserId", "OccurrenceKey")
.IsUnique()
.HasDatabaseName("IX_listening_event_occurrence");
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "State", "ListenedAt", "Id")
.HasDatabaseName("IX_listening_event_scope_history");
b.ToTable("listening_events", null, t =>
{
t.HasCheckConstraint("CK_listening_event_duration", "\"DurationMilliseconds\" IS NULL OR \"DurationMilliseconds\" > 0");
t.HasCheckConstraint("CK_listening_event_musicbrainz_confidence", "\"MusicBrainzEnrichmentConfidence\" IS NULL OR (\"MusicBrainzEnrichmentConfidence\" >= 0 AND \"MusicBrainzEnrichmentConfidence\" <= 1)");
t.HasCheckConstraint("CK_listening_event_musicbrainz_state", "\"MusicBrainzEnrichmentState\" IN ('NotRequested', 'Pending', 'Resolved', 'Unresolved', 'Failed')");
t.HasCheckConstraint("CK_listening_event_position", "\"PositionTicks\" IS NULL OR \"PositionTicks\" >= 0");
t.HasCheckConstraint("CK_listening_event_track_number", "\"TrackNumber\" IS NULL OR \"TrackNumber\" > 0");
});
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ApplyGeneration")
.HasColumnType("integer");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<string>("ContentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("DisplayFileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("DuplicateRows")
.HasColumnType("bigint");
b.Property<long>("ExpiresAt")
.HasColumnType("bigint");
b.Property<string>("Format")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("ImportedRows")
.HasColumnType("bigint");
b.Property<Guid?>("JobId")
.HasColumnType("uuid");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<long>("NextSequence")
.HasColumnType("bigint");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PreviewJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PreviewRevision")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("ResolvedRows")
.HasColumnType("bigint");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UnresolvedRows")
.HasColumnType("bigint");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("JobId")
.HasDatabaseName("IX_listening_history_import_job");
b.HasIndex("TenantId", "OwnerUserId", "ContentSha256")
.HasDatabaseName("IX_listening_history_import_content");
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "CreatedAt")
.HasDatabaseName("IX_listening_history_import_scope");
b.ToTable("listening_history_imports", null, t =>
{
t.HasCheckConstraint("CK_listening_history_import_counts", "\"NextSequence\" >= 0 AND \"ImportedRows\" >= 0 AND \"DuplicateRows\" >= 0 AND \"ResolvedRows\" >= 0 AND \"UnresolvedRows\" >= 0");
t.HasCheckConstraint("CK_listening_history_import_size", "\"SizeBytes\" > 0");
t.HasCheckConstraint("CK_listening_history_import_state", "\"State\" IN ('Previewed', 'Pending', 'Running', 'Completed', 'Cancelled', 'Failed', 'Expired')");
});
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningIntakeTokenRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("RelayExternally")
.HasColumnType("boolean");
b.Property<long?>("RevokedAt")
.HasColumnType("bigint");
b.Property<Guid>("SecretReferenceId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SecretReferenceId")
.IsUnique()
.HasDatabaseName("IX_listening_intake_token_secret");
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "CreatedAt")
.HasDatabaseName("IX_listening_intake_token_scope");
b.ToTable("listening_intake_tokens", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningProfileRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("ProfileJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("WindowEnd")
.HasColumnType("bigint");
b.Property<long>("WindowStart")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "OwnerUserId", "CreatedAt");
b.ToTable("listening_profiles", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningSignalRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("ExpiresAt")
.HasColumnType("bigint");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<long>("ObservedAt")
.HasColumnType("bigint");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SignalKey")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SignalType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("SourceJobId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TrackKeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("TrackReference")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<double>("Value")
.HasColumnType("double precision");
b.HasKey("Id");
b.HasIndex("SourceJobId");
b.HasIndex("TenantId", "OwnerUserId", "ExpiresAt");
b.HasIndex("TenantId", "OwnerUserId", "SignalKey")
.IsUnique()
.HasDatabaseName("IX_listening_signal_idempotency")
.HasFilter("\"SignalKey\" IS NOT NULL");
b.ToTable("listening_signals", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationCandidateRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid?>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("ExclusionsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("IdentityJson")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("RunId")
.HasColumnType("uuid");
b.Property<double>("Score")
.HasColumnType("double precision");
b.Property<string>("SignalsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SourceRevision")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TrackKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("ProviderAccountId");
b.HasIndex("RunId", "Position")
.IsUnique();
b.HasIndex("TenantId", "CanonicalRecordingId");
b.HasIndex("RunId", "TenantId", "OwnerUserId");
b.ToTable("recommendation_candidates", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationFeedbackRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("CandidateId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ReasonCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TrackKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CandidateId", "TenantId", "OwnerUserId")
.IsUnique();
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "TrackKey");
b.ToTable("recommendation_feedback", (string)null);
});
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationRunRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("ErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("JobId")
.HasColumnType("uuid");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int>("Limit")
.HasColumnType("integer");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PolicySnapshotJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid?>("ScheduleId")
.HasColumnType("uuid");
b.Property<long?>("ScheduledFor")
.HasColumnType("bigint");
b.Property<string>("SeedTrackKeysJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("TargetCredentialReferenceId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("TargetCredentialReferenceId")
.HasDatabaseName("IX_recommendation_run_credential_reference");
b.HasIndex("ScheduleId", "ScheduledFor")
.IsUnique()
.HasDatabaseName("IX_recommendation_run_schedule_occurrence")
.HasFilter("\"ScheduleId\" IS NOT NULL");
b.HasIndex("TenantId", "OwnerUserId", "IdempotencyKey")
.IsUnique()
.HasDatabaseName("IX_recommendation_run_idempotency");
b.HasIndex("TenantId", "OwnerUserId", "ScheduleId", "ScheduledFor")
.HasDatabaseName("IX_recommendation_run_schedule_history");
b.ToTable("recommendation_runs", null, t =>
{
t.HasCheckConstraint("CK_recommendation_run_schedule_pair", "(\"ScheduleId\" IS NULL AND \"ScheduledFor\" IS NULL) OR (\"ScheduleId\" IS NOT NULL AND \"ScheduledFor\" IS NOT NULL)");
});
});
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CanonicalPath")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("ContentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("FileSystemDeviceId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("FileSystemFileId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("FileSystemLinkCount")
.HasColumnType("bigint");
b.Property<bool>("IsManaged")
.HasColumnType("boolean");
b.Property<long>("Length")
.HasColumnType("bigint");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PlacementMethod")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("ReferenceCount")
.HasColumnType("integer");
b.Property<long?>("RemovedAt")
.HasColumnType("bigint");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("RootId")
.HasColumnType("uuid");
b.Property<string>("ScopeKey")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("SourceJobId")
.HasColumnType("uuid");
b.Property<string>("TargetRootPath")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CanonicalPath")
.IsUnique()
.HasDatabaseName("IX_managed_file_path");
b.HasIndex("SourceJobId")
.HasDatabaseName("IX_managed_file_job");
b.HasIndex("TenantId", "OwnerUserId")
.HasDatabaseName("IX_managed_file_user");
b.HasIndex("Id", "TenantId", "OwnerUserId")
.IsUnique()
.HasDatabaseName("UX_managed_file_owner_lineage");
b.HasIndex("RootId", "ContentSha256", "ScopeKey")
.HasDatabaseName("IX_managed_file_fingerprint");
b.ToTable("managed_files", null, t =>
{
t.HasCheckConstraint("CK_managed_files_owned", "\"IsManaged\" = TRUE");
t.HasCheckConstraint("CK_managed_files_references", "\"ReferenceCount\" >= 0");
t.HasCheckConstraint("CK_managed_files_sha256", "length(\"ContentSha256\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileReferenceEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("ManagedFileId")
.HasColumnType("uuid");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("ReferenceKey")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long?>("ReleasedAt")
.HasColumnType("bigint");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("ScopeKey")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ManagedFileId", "ReferenceKey")
.IsUnique()
.HasDatabaseName("IX_managed_file_reference_key");
b.HasIndex("TenantId", "ManagedFileId");
b.HasIndex("TenantId", "OwnerUserId", "ReleasedAt")
.HasDatabaseName("IX_managed_file_reference_owner");
b.ToTable("managed_file_references", (string)null);
});
modelBuilder.Entity("allstarr.Core.Playback.PlaybackDeliveryCheckpointEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("OccurrenceKey")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("ProviderCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("RequiresReauthentication")
.HasColumnType("boolean");
b.Property<long?>("RetryAfter")
.HasColumnType("bigint");
b.Property<string>("SafeMessage")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("SignalKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "OwnerUserId", "OccurrenceKey", "Kind")
.HasDatabaseName("IX_playback_delivery_occurrence_status");
b.HasIndex("TenantId", "OwnerUserId", "SignalKey", "TargetId")
.IsUnique()
.HasDatabaseName("IX_playback_delivery_idempotency");
b.ToTable("playback_delivery_checkpoints", null, t =>
{
t.HasCheckConstraint("CK_playback_delivery_checkpoint_state", "\"State\" IN ('Delivered', 'Ignored', 'Retrying', 'PermanentFailure')");
});
});
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteDecisionEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<string>("CandidateDecisionsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid?>("DurableJobId")
.HasColumnType("uuid");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("OperationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RouteKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("SelectedProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("SelectedProviderId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("DurableJobId");
b.HasIndex("SelectedProviderAccountId", "SelectedProviderId");
b.HasIndex("TenantId", "ActorUserId");
b.HasIndex("TenantId", "RouteKey")
.IsUnique()
.HasDatabaseName("IX_provider_route_decision_key");
b.HasIndex("TenantId", "CorrelationId", "CreatedAt")
.HasDatabaseName("IX_provider_route_decision_correlation");
b.ToTable("provider_route_decisions", (string)null);
});
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteOutcomeEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("NextProviderId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("OutcomeKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ReasonCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("RouteDecisionId")
.HasColumnType("uuid");
b.Property<int>("Sequence")
.HasColumnType("integer");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProviderAccountId", "ProviderId");
b.HasIndex("RouteDecisionId", "OutcomeKey")
.IsUnique()
.HasDatabaseName("IX_provider_route_outcome_key");
b.HasIndex("RouteDecisionId", "TenantId");
b.HasIndex("TenantId", "CreatedAt")
.HasDatabaseName("IX_provider_route_outcome_tenant_created");
b.ToTable("provider_route_outcomes", (string)null);
});
modelBuilder.Entity("allstarr.Core.Settings.TenantRuntimeSettingRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.Property<Guid?>("UpdatedByUserId")
.HasColumnType("uuid");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("ValueType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("TenantId", "Key")
.IsUnique();
b.HasIndex("TenantId", "UpdatedByUserId");
b.ToTable("tenant_runtime_settings", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.AdminAuthSessionRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("ExpiresAt")
.HasColumnType("bigint");
b.Property<long>("LastSeenAt")
.HasColumnType("bigint");
b.Property<string>("ProtectedPayload")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.ToTable("admin_auth_sessions", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.AdminOidcLinkRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("BackendIdentityId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("SecretReferenceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("BackendIdentityId")
.IsUnique();
b.HasIndex("SecretReferenceId");
b.ToTable("admin_oidc_links", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ApplicationCacheEntryRecord", b =>
{
b.Property<string>("Key")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<long?>("ExpiresAt")
.HasColumnType("bigint");
b.Property<int>("PayloadBytes")
.HasColumnType("integer");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("Key");
b.HasIndex("ExpiresAt")
.HasDatabaseName("IX_application_cache_expires_at");
b.HasIndex("Category", "UpdatedAt")
.HasDatabaseName("IX_application_cache_category_updated");
b.ToTable("application_cache_entries", null, t =>
{
t.HasCheckConstraint("CK_application_cache_payload_bytes", "\"PayloadBytes\" >= 0 AND \"PayloadBytes\" <= 1048576");
});
});
modelBuilder.Entity("allstarr.Core.Storage.AuditEventRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("CorrelationId");
b.HasIndex("TenantId", "CreatedAt", "Id")
.HasDatabaseName("IX_audit_event_updates");
b.ToTable("audit_events", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.BackendIdentityRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BackendType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("LastSeenAt")
.HasColumnType("bigint");
b.Property<string>("PrincipalId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("TenantId", "UserId");
b.HasIndex("BackendType", "BackendInstanceId", "PrincipalId")
.IsUnique();
b.ToTable("backend_identities", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.BackupRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ApplicationVersion")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ArtifactPath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("RestoreStatus")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long?>("RestoreVerifiedAt")
.HasColumnType("bigint");
b.Property<string>("SchemaVersion")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StorageProvider")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long?>("VerifiedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("backups", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalArtistRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Disambiguation")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsProvisional")
.HasColumnType("boolean");
b.Property<string>("MusicBrainzArtistId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SortName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "MusicBrainzArtistId")
.IsUnique();
b.HasIndex("TenantId", "SortName", "Id");
b.ToTable("canonical_artists", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalCatalogAliasRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalEntityId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("EntityKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalIdHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("LastSeenAt")
.HasColumnType("bigint");
b.Property<string>("Namespace")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "EntityKind", "CanonicalEntityId");
b.HasIndex("TenantId", "Namespace", "EntityKind", "ExternalIdHash")
.IsUnique();
b.ToTable("canonical_catalog_aliases", null, t =>
{
t.HasCheckConstraint("CK_canonical_catalog_alias_hash", "length(\"ExternalIdHash\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingArtistRecord", b =>
{
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("CanonicalArtistId")
.HasColumnType("uuid");
b.Property<string>("CreditName")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("JoinPhrase")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("TenantId", "CanonicalRecordingId", "Position");
b.HasIndex("TenantId", "CanonicalArtistId", "CanonicalRecordingId");
b.ToTable("canonical_recording_artists", null, t =>
{
t.HasCheckConstraint("CK_canonical_recording_artist_position", "\"Position\" >= 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Disambiguation")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long?>("DurationMilliseconds")
.HasColumnType("bigint");
b.Property<bool?>("IsExplicit")
.HasColumnType("boolean");
b.Property<bool>("IsProvisional")
.HasColumnType("boolean");
b.Property<string>("Isrc")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("MusicBrainzRecordingId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "CreatedByUserId");
b.HasIndex("TenantId", "Isrc")
.IsUnique();
b.HasIndex("TenantId", "MusicBrainzRecordingId")
.IsUnique();
b.HasIndex("TenantId", "Title", "Id");
b.ToTable("canonical_recordings", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupArtistRecord", b =>
{
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalReleaseGroupId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("CanonicalArtistId")
.HasColumnType("uuid");
b.Property<string>("CreditName")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("JoinPhrase")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("TenantId", "CanonicalReleaseGroupId", "Position");
b.HasIndex("TenantId", "CanonicalArtistId", "CanonicalReleaseGroupId");
b.ToTable("canonical_release_group_artists", null, t =>
{
t.HasCheckConstraint("CK_canonical_release_group_artist_position", "\"Position\" >= 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("FirstReleaseDate")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("IsProvisional")
.HasColumnType("boolean");
b.Property<string>("MusicBrainzReleaseGroupId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PrimaryType")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SecondaryTypesJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "MusicBrainzReleaseGroupId")
.IsUnique();
b.HasIndex("TenantId", "Title", "Id");
b.ToTable("canonical_release_groups", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Barcode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("CanonicalReleaseGroupId")
.HasColumnType("uuid");
b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Disambiguation")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsProvisional")
.HasColumnType("boolean");
b.Property<string>("MusicBrainzReleaseId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ReleaseDate")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("Status")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "MusicBrainzReleaseId")
.IsUnique();
b.HasIndex("TenantId", "CanonicalReleaseGroupId", "ReleaseDate");
b.ToTable("canonical_releases", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseTrackRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalReleaseId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<long?>("DurationMilliseconds")
.HasColumnType("bigint");
b.Property<int>("MediumPosition")
.HasColumnType("integer");
b.Property<string>("MusicBrainzTrackId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("TrackPosition")
.HasColumnType("integer");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasAlternateKey("TenantId", "Id");
b.HasIndex("TenantId", "CanonicalRecordingId");
b.HasIndex("TenantId", "MusicBrainzTrackId")
.IsUnique();
b.HasIndex("TenantId", "CanonicalReleaseId", "MediumPosition", "TrackPosition")
.IsUnique();
b.ToTable("canonical_release_tracks", null, t =>
{
t.HasCheckConstraint("CK_canonical_release_track_position", "\"MediumPosition\" > 0 AND \"TrackPosition\" > 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.CatalogFactRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalEntityId")
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<string>("EntityKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("ObservedAt")
.HasColumnType("bigint");
b.Property<string>("PayloadSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("RefreshAfter")
.HasColumnType("bigint");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SourceRevision")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long?>("SupersededAt")
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.HasIndex("TenantId", "SourceId", "RefreshAfter");
b.HasIndex("TenantId", "EntityKind", "CanonicalEntityId", "FieldName");
b.ToTable("catalog_facts", null, t =>
{
t.HasCheckConstraint("CK_catalog_fact_confidence", "\"Confidence\" >= 0 AND \"Confidence\" <= 1");
t.HasCheckConstraint("CK_catalog_fact_payload_hash", "length(\"PayloadSha256\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.Storage.DurableJobRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AttemptCount")
.HasColumnType("integer");
b.Property<long>("AvailableAt")
.HasColumnType("bigint");
b.Property<long?>("CancellationRequestedAt")
.HasColumnType("bigint");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<int>("DeferralCount")
.HasColumnType("integer");
b.Property<int>("FailureCount")
.HasColumnType("integer");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("LastErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LastErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long?>("LeaseExpiresAt")
.HasColumnType("bigint");
b.Property<string>("LeaseOwner")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int>("MaxAttempts")
.HasColumnType("integer");
b.Property<int>("MaxDeferrals")
.HasColumnType("integer");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PolicySnapshotJson")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderCapability")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RequestFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("ScopeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("StartedAt")
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ProviderAccountId");
b.HasIndex("Id", "TenantId")
.IsUnique()
.HasDatabaseName("UX_durable_job_tenant_lineage");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("Id", "TenantId", "OwnerUserId")
.IsUnique()
.HasDatabaseName("UX_durable_job_owner_lineage");
b.HasIndex("ScopeKey", "Type", "IdempotencyKey")
.IsUnique();
b.HasIndex("State", "AvailableAt", "Priority");
b.HasIndex("TenantId", "UpdatedAt", "Id")
.HasDatabaseName("IX_durable_job_updates");
b.ToTable("durable_jobs", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionLogRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("EventCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ExtensionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ExtensionPackageId")
.HasColumnType("uuid");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("ExtensionPackageId");
b.HasIndex("ExtensionId", "CreatedAt");
b.ToTable("extension_logs", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPackageRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long?>("ActivatedAt")
.HasColumnType("bigint");
b.Property<string>("ContentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("DisabledAt")
.HasColumnType("bigint");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExtensionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("FailureCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ManifestJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PackagePath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("PreviousPackageId")
.HasColumnType("uuid");
b.Property<Guid?>("RegistryId")
.HasColumnType("uuid");
b.Property<long?>("ReviewedAt")
.HasColumnType("bigint");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SdkVersion")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("StagedAt")
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("PreviousPackageId");
b.HasIndex("RegistryId");
b.HasIndex("ExtensionId", "State");
b.HasIndex("ExtensionId", "Version", "Sha256");
b.ToTable("extension_packages", null, t =>
{
t.HasCheckConstraint("CK_extension_packages_content_hash", "length(\"ContentSha256\") = 64");
t.HasCheckConstraint("CK_extension_packages_sha256", "length(\"Sha256\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPermissionReviewRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("ExtensionPackageId")
.HasColumnType("uuid");
b.Property<string>("PermissionKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("PermissionValue")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("Required")
.HasColumnType("boolean");
b.Property<long?>("ReviewedAt")
.HasColumnType("bigint");
b.Property<Guid?>("ReviewedByUserId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ReviewedByUserId");
b.HasIndex("ExtensionPackageId", "PermissionKind", "PermissionValue")
.IsUnique()
.HasDatabaseName("IX_extension_permission_review_key");
b.ToTable("extension_permission_reviews", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionRegistryRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("RegistryUrl")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RegistryUrl")
.IsUnique();
b.ToTable("extension_registries", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BackendPrincipalId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ExternalIdHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PayloadSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ProviderRevision")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("ProviderTrackIdentityId")
.HasColumnType("uuid");
b.Property<string>("ResourceKind")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("RetrievedAt")
.HasColumnType("bigint");
b.Property<int>("SnapshotVersion")
.HasColumnType("integer");
b.Property<Guid?>("SourceJobId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProviderTrackIdentityId");
b.HasIndex("SourceJobId");
b.HasIndex("ProviderAccountId", "ProviderId");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("TenantId", "ProviderAccountId", "ResourceKind", "ExternalIdHash", "SnapshotVersion")
.IsUnique()
.HasDatabaseName("IX_external_snapshot_version");
b.ToTable("external_metadata_snapshots", null, t =>
{
t.HasCheckConstraint("CK_external_snapshots_external_hash", "length(\"ExternalIdHash\") = 64");
t.HasCheckConstraint("CK_external_snapshots_payload_hash", "length(\"PayloadSha256\") = 64");
t.HasCheckConstraint("CK_external_snapshots_version", "\"SnapshotVersion\" > 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.JobAttemptRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AttemptNumber")
.HasColumnType("integer");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<string>("ErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid>("JobId")
.HasColumnType("uuid");
b.Property<string>("Outcome")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("StartedAt")
.HasColumnType("bigint");
b.Property<string>("WorkerId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("JobId", "AttemptNumber")
.IsUnique();
b.ToTable("job_attempts", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.JobScheduleRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("CronExpression")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("JobType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("MisfirePolicy")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long?>("NextRunAt")
.HasColumnType("bigint");
b.Property<string>("OverlapPolicy")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PayloadTemplateJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("{}");
b.Property<string>("RetryPolicyJson")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TimeZoneId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("Enabled", "NextRunAt");
b.HasIndex("TenantId", "OwnerUserId");
b.ToTable("job_schedules", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.LegacyEnvImportRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<long>("AppliedAt")
.HasColumnType("bigint");
b.Property<Guid>("AuditEventId")
.HasColumnType("uuid");
b.Property<string>("ProvenanceJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SchemaVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SourceSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AuditEventId")
.IsUnique();
b.HasIndex("TenantId", "ActorUserId");
b.HasIndex("TenantId", "SourceSha256", "SchemaVersion")
.IsUnique();
b.ToTable("legacy_env_imports", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.LibraryTrackRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int?>("AcceptedDecisionVersion")
.HasColumnType("integer");
b.Property<string>("Album")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AlbumArtist")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("BackendIdentityId")
.HasColumnType("uuid");
b.Property<string>("BackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BackendItemId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid?>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<string>("CoverArtReference")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long?>("DurationMilliseconds")
.HasColumnType("bigint");
b.Property<string>("DurationProvenance")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("DurationRetrievedAt")
.HasColumnType("bigint");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<long>("IndexedAt")
.HasColumnType("bigint");
b.Property<string>("Isrc")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("MusicBrainzArtistId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("MusicBrainzRecordingId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("MusicBrainzReleaseId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ProviderIdsJson")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<long>("SourceModifiedAt")
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BackendIdentityId");
b.HasIndex("TenantId", "CanonicalRecordingId");
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "Isrc")
.HasDatabaseName("IX_library_track_scoped_isrc");
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "MusicBrainzRecordingId")
.HasDatabaseName("IX_library_track_scoped_musicbrainz");
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "BackendInstanceId", "BackendItemId")
.IsUnique()
.HasDatabaseName("IX_library_track_backend_item");
b.ToTable("library_tracks", null, t =>
{
t.HasCheckConstraint("CK_library_tracks_decision_version", "\"AcceptedDecisionVersion\" IS NULL OR \"AcceptedDecisionVersion\" > 0");
t.HasCheckConstraint("CK_library_tracks_duration", "\"DurationMilliseconds\" IS NULL OR \"DurationMilliseconds\" > 0");
t.HasCheckConstraint("CK_library_tracks_stable_artwork", "\"CoverArtReference\" IS NULL OR \"CoverArtReference\" NOT LIKE '%://%'");
});
});
modelBuilder.Entity("allstarr.Core.Storage.ManualLyricsMappingRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Album")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<int>("DurationSeconds")
.HasColumnType("integer");
b.Property<string>("IdentityHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("LyricsId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("IdentityHash")
.IsUnique();
b.HasIndex("UpdatedAt");
b.ToTable("manual_lyrics_mappings", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ManualTrackOverrideRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("DecisionVersion")
.HasColumnType("integer");
b.Property<Guid>("ExternalSnapshotId")
.HasColumnType("uuid");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("LibraryTrackId")
.HasColumnType("uuid");
b.Property<string>("MatcherVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<long?>("RevokedAt")
.HasColumnType("bigint");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "ExternalSnapshotId");
b.HasIndex("TenantId", "LibraryTrackId");
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "ExternalSnapshotId")
.IsUnique()
.HasDatabaseName("IX_manual_track_override_active")
.HasFilter("\"RevokedAt\" IS NULL");
b.ToTable("manual_track_overrides", null, t =>
{
t.HasCheckConstraint("CK_manual_overrides_shape", "(\"Decision\" = 'Pin' AND \"LibraryTrackId\" IS NOT NULL) OR \"Decision\" = 'Reject'");
t.HasCheckConstraint("CK_manual_overrides_version", "\"DecisionVersion\" > 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentApplicationRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ArtifactContentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("ErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("LineageJobId")
.HasColumnType("uuid");
b.Property<Guid>("ManagedArtifactId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<Guid>("PlanId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SafeErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("LineageJobId")
.HasDatabaseName("IX_enrichment_application_job");
b.HasIndex("PlanId", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
.HasDatabaseName("IX_enrichment_application_plan");
b.HasIndex("TenantId", "OwnerUserId", "PlanId", "ManagedArtifactId", "ArtifactContentSha256")
.IsUnique()
.HasDatabaseName("IX_enrichment_application_hash");
b.ToTable("metadata_enrichment_applications", null, t =>
{
t.HasCheckConstraint("CK_enrichment_applications_sha256", "length(\"ArtifactContentSha256\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentPlanRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("DecisionsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Fingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("LineageJobId")
.HasColumnType("uuid");
b.Property<Guid>("ManagedArtifactId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PathValuesJson")
.IsRequired()
.HasColumnType("text");
b.Property<int>("PlanVersion")
.HasColumnType("integer");
b.Property<string>("SourceRevisionsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasAlternateKey("Id", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
.HasName("AK_enrichment_plan_scope");
b.HasIndex("LineageJobId")
.HasDatabaseName("IX_enrichment_plan_job");
b.HasIndex("ManagedArtifactId")
.HasDatabaseName("IX_enrichment_plan_file");
b.HasIndex("TenantId", "OwnerUserId", "ManagedArtifactId", "Fingerprint")
.IsUnique()
.HasDatabaseName("IX_enrichment_plan_fingerprint");
b.ToTable("metadata_enrichment_plans", null, t =>
{
t.HasCheckConstraint("CK_enrichment_plans_fingerprint", "length(\"Fingerprint\") = 64");
});
});
modelBuilder.Entity("allstarr.Core.Storage.OnboardingStateRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<string>("CompletedStepsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CompletionSource")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<long?>("ReopenedAt")
.HasColumnType("bigint");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("SchemaVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "UserId")
.IsUnique();
b.ToTable("onboarding_states", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.OutboxMessageRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AttemptCount")
.HasColumnType("integer");
b.Property<long>("AvailableAt")
.HasColumnType("bigint");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<long?>("DeliveredAt")
.HasColumnType("bigint");
b.Property<long?>("FailedAt")
.HasColumnType("bigint");
b.Property<string>("LastErrorCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LastErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long?>("LeaseExpiresAt")
.HasColumnType("bigint");
b.Property<string>("LeaseOwner")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("MaxAttempts")
.HasColumnType("integer");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("State", "AvailableAt");
b.HasIndex("TenantId", "UpdatedAt", "Id")
.HasDatabaseName("IX_outbox_updates");
b.ToTable("outbox_messages", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.PlatformUserRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistLinkRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("ImportMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Linked");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("MaterializationMode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("MirrorStaleEntries")
.HasColumnType("boolean");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PolicyVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("PreserveManualEntries")
.HasColumnType("boolean");
b.Property<string>("ProjectionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Resolved");
b.Property<Guid>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("RuleVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ScheduleId")
.HasColumnType("uuid");
b.Property<string>("SourcePlaylistId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("SourcePlaylistIdHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SourceProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("SyncArtwork")
.HasColumnType("boolean");
b.Property<bool>("SyncDescription")
.HasColumnType("boolean");
b.Property<bool>("SyncName")
.HasColumnType("boolean");
b.Property<string>("TargetBackendInstanceId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("TargetCredentialReferenceId")
.HasColumnType("uuid");
b.Property<string>("TargetPlaylistId")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("TargetProtocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TrackRetention")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("OnDemand");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ProviderAccountId", "SourceProviderId");
b.HasIndex("TenantId", "ScheduleId");
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "SourceProviderId", "ProviderAccountId", "SourcePlaylistIdHash", "TargetProtocol", "TargetBackendInstanceId")
.IsUnique()
.HasDatabaseName("IX_playlist_link_source_target");
b.ToTable("playlist_links", null, t =>
{
t.HasCheckConstraint("CK_playlist_links_import_mode", "\"ImportMode\" IN ('Linked', 'OneTime')");
t.HasCheckConstraint("CK_playlist_links_one_time_schedule", "\"ImportMode\" <> 'OneTime' OR \"ScheduleId\" IS NULL");
t.HasCheckConstraint("CK_playlist_links_source_hash", "length(\"SourcePlaylistIdHash\") = 64");
t.HasCheckConstraint("CK_playlist_links_track_retention", "\"TrackRetention\" IN ('OnDemand', 'KeepAll')");
});
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceEntryRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ExternalMetadataSnapshotId")
.HasColumnType("uuid");
b.Property<Guid>("PlaylistSourceSnapshotId")
.HasColumnType("uuid");
b.Property<Guid?>("PublishedTrackMatchId")
.HasColumnType("uuid");
b.Property<string>("SourceEntryIdHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("SourcePosition")
.HasColumnType("integer");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "ExternalMetadataSnapshotId");
b.HasIndex("TenantId", "PublishedTrackMatchId");
b.HasIndex("TenantId", "PlaylistSourceSnapshotId", "SourcePosition")
.IsUnique()
.HasDatabaseName("IX_playlist_source_entry_position");
b.ToTable("playlist_source_entries", null, t =>
{
t.HasCheckConstraint("CK_playlist_source_entry_hash", "length(\"SourceEntryIdHash\") = 64");
t.HasCheckConstraint("CK_playlist_source_entry_position", "\"SourcePosition\" >= 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ArtworkReferenceKey")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("ETag")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PayloadSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("PlaylistLinkId")
.HasColumnType("uuid");
b.Property<Guid>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderRevision")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<long?>("PublishedAt")
.HasColumnType("bigint");
b.Property<long>("RetrievedAt")
.HasColumnType("bigint");
b.Property<int>("SnapshotVersion")
.HasColumnType("integer");
b.Property<Guid?>("SourceJobId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProviderAccountId");
b.HasIndex("SourceJobId");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("TenantId", "PlaylistLinkId", "SnapshotVersion")
.IsUnique()
.HasDatabaseName("IX_playlist_snapshot_version");
b.HasIndex("TenantId", "RetrievedAt", "Id")
.HasDatabaseName("IX_playlist_snapshot_updates");
b.HasIndex("TenantId", "PlaylistLinkId", "PublishedAt", "SnapshotVersion")
.HasDatabaseName("IX_playlist_snapshot_published");
b.ToTable("playlist_source_snapshots", null, t =>
{
t.HasCheckConstraint("CK_playlist_snapshots_payload_hash", "length(\"PayloadSha256\") = 64");
t.HasCheckConstraint("CK_playlist_snapshots_stable_artwork", "\"ArtworkReferenceKey\" IS NULL OR \"ArtworkReferenceKey\" NOT LIKE '%://%'");
t.HasCheckConstraint("CK_playlist_snapshots_version", "\"SnapshotVersion\" > 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncEntryResultRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("LibraryTrackId")
.HasColumnType("uuid");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("OutcomeCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("PlaylistSourceEntryId")
.HasColumnType("uuid");
b.Property<Guid>("PlaylistSyncRunId")
.HasColumnType("uuid");
b.Property<int>("SourcePosition")
.HasColumnType("integer");
b.Property<int?>("TargetPosition")
.HasColumnType("integer");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<Guid?>("TrackMatchId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "LibraryTrackId");
b.HasIndex("TenantId", "PlaylistSourceEntryId");
b.HasIndex("TenantId", "TrackMatchId");
b.HasIndex("TenantId", "PlaylistSyncRunId", "SourcePosition")
.IsUnique()
.HasDatabaseName("IX_playlist_result_run_position");
b.ToTable("playlist_sync_entry_results", null, t =>
{
t.HasCheckConstraint("CK_playlist_result_positions", "\"SourcePosition\" >= 0 AND (\"TargetPosition\" IS NULL OR \"TargetPosition\" >= 0)");
});
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncRunRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long?>("CompletedAt")
.HasColumnType("bigint");
b.Property<string>("ConflictCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Generation")
.HasColumnType("bigint");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("JobId")
.HasColumnType("uuid");
b.Property<string>("MaterializationMode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<long?>("PlannedTargetDurationMilliseconds")
.HasColumnType("bigint");
b.Property<int?>("PlannedTargetTrackCount")
.HasColumnType("integer");
b.Property<Guid>("PlaylistLinkId")
.HasColumnType("uuid");
b.Property<Guid>("PlaylistSourceSnapshotId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("RuleVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ScheduleId")
.HasColumnType("uuid");
b.Property<long>("StartedAt")
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetRevisionAfter")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("TargetRevisionBefore")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("VerificationCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("VerifiedAt")
.HasColumnType("bigint");
b.Property<long?>("VerifiedTargetDurationMilliseconds")
.HasColumnType("bigint");
b.Property<int?>("VerifiedTargetTrackCount")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("TenantId", "PlaylistSourceSnapshotId");
b.HasIndex("TenantId", "ScheduleId");
b.HasIndex("TenantId", "PlaylistLinkId", "IdempotencyKey")
.IsUnique();
b.ToTable("playlist_sync_runs", null, t =>
{
t.HasCheckConstraint("CK_playlist_sync_generation", "\"Generation\" > 0");
t.HasCheckConstraint("CK_playlist_sync_verification_counts", "(\"PlannedTargetTrackCount\" IS NULL OR \"PlannedTargetTrackCount\" >= 0) AND (\"VerifiedTargetTrackCount\" IS NULL OR \"VerifiedTargetTrackCount\" >= 0)");
t.HasCheckConstraint("CK_playlist_sync_verification_durations", "(\"PlannedTargetDurationMilliseconds\" IS NULL OR \"PlannedTargetDurationMilliseconds\" >= 0) AND (\"VerifiedTargetDurationMilliseconds\" IS NULL OR \"VerifiedTargetDurationMilliseconds\" >= 0)");
});
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistTargetMembershipRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("CreatedBySyncRunId")
.HasColumnType("uuid");
b.Property<int>("LastKnownPosition")
.HasColumnType("integer");
b.Property<Guid>("LibraryTrackId")
.HasColumnType("uuid");
b.Property<Guid>("PlaylistLinkId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("TargetEntryId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TenantId", "CreatedBySyncRunId");
b.HasIndex("TenantId", "LibraryTrackId");
b.HasIndex("TenantId", "PlaylistLinkId", "TargetEntryId")
.IsUnique()
.HasDatabaseName("IX_playlist_membership_target_entry");
b.HasIndex("TenantId", "PlaylistLinkId", "LibraryTrackId", "Active")
.HasDatabaseName("IX_playlist_membership_track_active");
b.ToTable("playlist_target_memberships", null, t =>
{
t.HasCheckConstraint("CK_playlist_membership_position", "\"LastKnownPosition\" >= 0");
});
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderAccountRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("LibraryScopeId")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("SecretReferenceId")
.HasColumnType("uuid");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("SecretReferenceId");
b.HasIndex("TenantId", "OwnerUserId");
b.HasIndex("ProviderId", "TenantId", "OwnerUserId");
b.ToTable("provider_accounts", null, t =>
{
t.HasCheckConstraint("CK_provider_accounts_scope_shape", "(\"Scope\" = 'Global' AND \"TenantId\" IS NULL AND \"OwnerUserId\" IS NULL AND \"LibraryScopeId\" IS NULL) OR (\"Scope\" = 'User' AND \"TenantId\" IS NOT NULL AND \"OwnerUserId\" IS NOT NULL AND \"LibraryScopeId\" IS NULL) OR (\"Scope\" = 'Library' AND \"TenantId\" IS NOT NULL AND \"OwnerUserId\" IS NULL AND \"LibraryScopeId\" IS NOT NULL)");
});
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderCircuitRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<long?>("OpenedAt")
.HasColumnType("bigint");
b.Property<Guid>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<long?>("RetryAfter")
.HasColumnType("bigint");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ProviderAccountId", "Capability")
.IsUnique();
b.ToTable("provider_circuits", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthRollupRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("FailureCount")
.HasColumnType("integer");
b.Property<string>("LastFailureCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LastState")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<long?>("P50LatencyMilliseconds")
.HasColumnType("bigint");
b.Property<long?>("P95LatencyMilliseconds")
.HasColumnType("bigint");
b.Property<Guid>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<int>("SampleCount")
.HasColumnType("integer");
b.Property<int>("SuccessCount")
.HasColumnType("integer");
b.Property<double>("SuccessRate")
.HasColumnType("double precision");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.Property<long>("WindowEnd")
.HasColumnType("bigint");
b.Property<long>("WindowStart")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("WindowEnd")
.HasDatabaseName("IX_provider_health_rollup_window_end");
b.HasIndex("ProviderAccountId", "Capability", "WindowStart")
.IsUnique()
.HasDatabaseName("IX_provider_health_rollup_account_capability_window");
b.ToTable("provider_health_rollups", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthSampleRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("ExpiresAt")
.HasColumnType("bigint");
b.Property<string>("FailureCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("LatencyMilliseconds")
.HasColumnType("bigint");
b.Property<long>("ObservedAt")
.HasColumnType("bigint");
b.Property<Guid>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProviderAccountId", "Capability", "ObservedAt")
.HasDatabaseName("IX_provider_health_account_capability_observed");
b.HasIndex("TenantId", "ObservedAt", "Id")
.HasDatabaseName("IX_provider_health_updates");
b.ToTable("provider_health_samples", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderTrackIdentityRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<string>("CatalogNamespace")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<int>("DecisionVersion")
.HasColumnType("integer");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalIdHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("ProviderAccountId")
.HasColumnType("uuid");
b.Property<string>("ProviderId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ResourceKind")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.Property<string>("Verification")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("VerificationMethod")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("VerifiedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ProviderAccountId", "ProviderId");
b.HasIndex("TenantId", "CanonicalRecordingId");
b.HasIndex("TenantId", "ProviderId", "ResourceKind", "CatalogNamespace", "ExternalIdHash")
.IsUnique()
.HasDatabaseName("IX_provider_track_identity_catalog_exact")
.HasFilter("\"Scope\" = 'Catalog'");
b.HasIndex("TenantId", "ProviderId", "ResourceKind", "CatalogNamespace", "ProviderAccountId", "ExternalIdHash")
.IsUnique()
.HasDatabaseName("IX_provider_track_identity_account_exact")
.HasFilter("\"Scope\" = 'Account'");
b.ToTable("provider_track_identities", null, t =>
{
t.HasCheckConstraint("CK_provider_track_identities_decision_version", "\"DecisionVersion\" > 0");
t.HasCheckConstraint("CK_provider_track_identities_external_hash", "length(\"ExternalIdHash\") = 64");
t.HasCheckConstraint("CK_provider_track_identities_scope_shape", "(\"Scope\" = 'Catalog' AND \"ProviderAccountId\" IS NULL) OR (\"Scope\" = 'Account' AND \"ProviderAccountId\" IS NOT NULL)");
t.HasCheckConstraint("CK_provider_track_identities_track_only", "\"ResourceKind\" = 'Track'");
t.HasCheckConstraint("CK_provider_track_identities_verification", "\"Verification\" IN ('Verified', 'Pinned')");
});
});
modelBuilder.Entity("allstarr.Core.Storage.SecretReferenceRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ActiveVersion")
.HasColumnType("integer");
b.Property<Guid?>("BackendIdentityId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long?>("RevokedAt")
.HasColumnType("bigint");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid");
b.Property<long>("UpdatedAt")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BackendIdentityId");
b.HasIndex("TenantId", "Purpose");
b.ToTable("secret_references", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.SecretVersionRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<byte[]>("AuthenticationTag")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("Ciphertext")
.IsRequired()
.HasColumnType("bytea");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("KeyId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<byte[]>("Nonce")
.IsRequired()
.HasColumnType("bytea");
b.Property<long?>("RetiredAt")
.HasColumnType("bigint");
b.Property<Guid>("SecretReferenceId")
.HasColumnType("uuid");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SecretReferenceId", "Version")
.IsUnique();
b.ToTable("secret_versions", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.TenantRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.TrackMatchRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CandidateResultsJson")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("CanonicalRecordingId")
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("DecidedAt")
.HasColumnType("bigint");
b.Property<int>("DecisionVersion")
.HasColumnType("integer");
b.Property<Guid>("ExternalSnapshotId")
.HasColumnType("uuid");
b.Property<long>("LibraryIndexRevision")
.HasColumnType("bigint");
b.Property<string>("LibraryScopeId")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("LibraryTrackId")
.HasColumnType("uuid");
b.Property<string>("MatcherVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("PolicyVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ReasonsJson")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Revision")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.Property<int>("SourceSnapshotVersion")
.HasColumnType("integer");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<double>("Threshold")
.HasColumnType("double precision");
b.Property<string>("WarningsJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TenantId", "CanonicalRecordingId");
b.HasIndex("TenantId", "ExternalSnapshotId");
b.HasIndex("TenantId", "LibraryTrackId");
b.HasIndex("TenantId", "DecidedAt", "Id")
.HasDatabaseName("IX_track_match_updates");
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "ExternalSnapshotId", "DecisionVersion")
.IsUnique()
.HasDatabaseName("IX_track_match_scoped_decision");
b.ToTable("track_matches", null, t =>
{
t.HasCheckConstraint("CK_track_matches_confidence", "\"Confidence\" >= 0 AND \"Confidence\" <= 1 AND \"Threshold\" >= 0 AND \"Threshold\" <= 1");
t.HasCheckConstraint("CK_track_matches_selected_shape", "(\"State\" IN ('Accepted', 'Suggested') AND (\"LibraryTrackId\" IS NOT NULL OR \"CanonicalRecordingId\" IS NOT NULL)) OR (\"State\" = 'Pinned' AND \"LibraryTrackId\" IS NOT NULL) OR (\"State\" IN ('Unresolved', 'Rejected', 'Ambiguous') AND \"LibraryTrackId\" IS NULL)");
t.HasCheckConstraint("CK_track_matches_version", "\"DecisionVersion\" > 0");
});
});
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadArtifactEntity", b =>
{
b.HasOne("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", null)
.WithMany()
.HasForeignKey("ManagedFileId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Downloads.ProviderDownloadWorkspaceEntity", null)
.WithMany()
.HasForeignKey("WorkspaceRecordId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadWorkspaceEntity", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("DurableJobId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_download_workspace_job");
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId", "ProviderId")
.HasPrincipalKey("Id", "ProviderId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_download_workspace_account");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_download_workspace_user");
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionPolicyRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "UpdatedByUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionRecord", b =>
{
b.HasOne("allstarr.Core.Favorites.FavoriteEventRecord", null)
.WithMany()
.HasForeignKey("EventId", "TenantId", "OwnerUserId")
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_favorite_action_event");
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteEventRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteStateRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Favorites.FavoriteEventRecord", null)
.WithMany()
.HasForeignKey("LastEventId", "TenantId", "OwnerUserId")
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_favorite_state_event");
});
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetEntryRecord", b =>
{
b.HasOne("allstarr.Core.Intelligence.GeneratedSetRecord", null)
.WithMany()
.HasForeignKey("GeneratedSetId", "TenantId", "OwnerUserId")
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetRecord", b =>
{
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
.WithMany()
.HasForeignKey("ScheduleId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Intelligence.RecommendationRunRecord", null)
.WithMany()
.HasForeignKey("RunId", "TenantId", "OwnerUserId")
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("allstarr.Core.Intelligence.IntelligencePolicyRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningEventRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_listening_event_provider_account");
b.HasOne("allstarr.Core.Storage.ProviderTrackIdentityRecord", null)
.WithMany()
.HasForeignKey("ProviderTrackIdentityId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_listening_event_provider_identity");
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_listening_event_canonical_recording");
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
.WithMany()
.HasForeignKey("TenantId", "LibraryTrackId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_listening_event_library_track");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningIntakeTokenRecord", b =>
{
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
.WithMany()
.HasForeignKey("SecretReferenceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_listening_intake_token_secret");
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningProfileRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningSignalRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("SourceJobId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_listening_signal_job");
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationCandidateRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Intelligence.RecommendationRunRecord", null)
.WithMany()
.HasForeignKey("RunId", "TenantId", "OwnerUserId")
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationFeedbackRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Intelligence.RecommendationCandidateRecord", null)
.WithOne()
.HasForeignKey("allstarr.Core.Intelligence.RecommendationFeedbackRecord", "CandidateId", "TenantId", "OwnerUserId")
.HasPrincipalKey("allstarr.Core.Intelligence.RecommendationCandidateRecord", "Id", "TenantId", "OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationRunRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
.WithMany()
.HasForeignKey("ScheduleId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("SourceJobId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_managed_file_tenant_job");
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_managed_file_tenant_user");
});
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileReferenceEntity", b =>
{
b.HasOne("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", null)
.WithMany()
.HasForeignKey("TenantId", "ManagedFileId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_managed_file_reference_tenant_file");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_managed_file_reference_tenant_user");
});
modelBuilder.Entity("allstarr.Core.Playback.PlaybackDeliveryCheckpointEntity", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteDecisionEntity", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("DurableJobId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_provider_route_decision_job");
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("SelectedProviderAccountId", "SelectedProviderId")
.HasPrincipalKey("Id", "ProviderId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_provider_route_decision_account");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ActorUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_provider_route_decision_actor");
});
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteOutcomeEntity", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId", "ProviderId")
.HasPrincipalKey("Id", "ProviderId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_provider_route_outcome_account");
b.HasOne("allstarr.Core.Routing.ProviderRouteDecisionEntity", null)
.WithMany()
.HasForeignKey("RouteDecisionId", "TenantId")
.HasPrincipalKey("Id", "TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_provider_route_outcome_decision");
});
modelBuilder.Entity("allstarr.Core.Settings.TenantRuntimeSettingRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "UpdatedByUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.AdminOidcLinkRecord", b =>
{
b.HasOne("allstarr.Core.Storage.BackendIdentityRecord", null)
.WithMany()
.HasForeignKey("BackendIdentityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
.WithMany()
.HasForeignKey("SecretReferenceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.AuditEventRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("ActorUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.BackendIdentityRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalArtistRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalCatalogAliasRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingArtistRecord", b =>
{
b.HasOne("allstarr.Core.Storage.CanonicalArtistRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalArtistId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CreatedByUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupArtistRecord", b =>
{
b.HasOne("allstarr.Core.Storage.CanonicalArtistRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalArtistId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalReleaseGroupRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalReleaseGroupId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalReleaseGroupRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalReleaseGroupId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseTrackRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalReleaseRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalReleaseId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.CatalogFactRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.DurableJobRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_durable_job_tenant_owner");
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionLogRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ExtensionPackageRecord", null)
.WithMany()
.HasForeignKey("ExtensionPackageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPackageRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ExtensionPackageRecord", null)
.WithMany()
.HasForeignKey("PreviousPackageId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.ExtensionRegistryRecord", null)
.WithMany()
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPermissionReviewRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ExtensionPackageRecord", null)
.WithMany()
.HasForeignKey("ExtensionPackageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_extension_permission_review_package");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("ReviewedByUserId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderTrackIdentityRecord", null)
.WithMany()
.HasForeignKey("ProviderTrackIdentityId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_external_snapshot_provider_identity");
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("SourceJobId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId", "ProviderId")
.HasPrincipalKey("Id", "ProviderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_external_snapshot_provider_account");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_ExternalMetadataSnapshot_PlatformUser");
});
modelBuilder.Entity("allstarr.Core.Storage.JobAttemptRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.JobScheduleRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_JobSchedule_PlatformUser");
});
modelBuilder.Entity("allstarr.Core.Storage.LegacyEnvImportRecord", b =>
{
b.HasOne("allstarr.Core.Storage.AuditEventRecord", null)
.WithMany()
.HasForeignKey("AuditEventId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ActorUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.LibraryTrackRecord", b =>
{
b.HasOne("allstarr.Core.Storage.BackendIdentityRecord", null)
.WithMany()
.HasForeignKey("BackendIdentityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_library_track_canonical_recording");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_LibraryTrack_PlatformUser");
});
modelBuilder.Entity("allstarr.Core.Storage.ManualTrackOverrideRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ExternalSnapshotId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_ManualTrackOverride_ExternalMetadataSnapshot");
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
.WithMany()
.HasForeignKey("TenantId", "LibraryTrackId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_ManualTrackOverride_LibraryTrack");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_ManualTrackOverride_PlatformUser");
});
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentApplicationRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("LineageJobId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.MetadataEnrichmentPlanRecord", null)
.WithMany()
.HasForeignKey("PlanId", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
.HasPrincipalKey("Id", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_enrichment_application_plan");
});
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentPlanRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("LineageJobId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", null)
.WithMany()
.HasForeignKey("ManagedArtifactId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.OnboardingStateRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "UserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.OutboxMessageRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.PlatformUserRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistLinkRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId", "SourceProviderId")
.HasPrincipalKey("Id", "ProviderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_playlist_link_provider_account");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistLink_PlatformUser");
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ScheduleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_PlaylistLink_JobSchedule");
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceEntryRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ExternalMetadataSnapshotId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSourceEntry_ExternalMetadataSnapshot");
b.HasOne("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistSourceSnapshotId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSourceEntry_PlaylistSourceSnapshot");
b.HasOne("allstarr.Core.Storage.TrackMatchRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PublishedTrackMatchId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_PlaylistSourceEntry_TrackMatch");
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_playlist_snapshot_provider_account");
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("SourceJobId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSourceSnapshot_PlatformUser");
b.HasOne("allstarr.Core.Storage.PlaylistLinkRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistLinkId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSourceSnapshot_PlaylistLink");
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncEntryResultRecord", b =>
{
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
.WithMany()
.HasForeignKey("TenantId", "LibraryTrackId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_PlaylistSyncEntryResult_LibraryTrack");
b.HasOne("allstarr.Core.Storage.PlaylistSourceEntryRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistSourceEntryId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSyncEntryResult_PlaylistSourceEntry");
b.HasOne("allstarr.Core.Storage.PlaylistSyncRunRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistSyncRunId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSyncEntryResult_PlaylistSyncRun");
b.HasOne("allstarr.Core.Storage.TrackMatchRecord", null)
.WithMany()
.HasForeignKey("TenantId", "TrackMatchId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_PlaylistSyncEntryResult_TrackMatch");
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncRunRecord", b =>
{
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSyncRun_PlatformUser");
b.HasOne("allstarr.Core.Storage.PlaylistLinkRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistLinkId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSyncRun_PlaylistLink");
b.HasOne("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistSourceSnapshotId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistSyncRun_PlaylistSourceSnapshot");
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ScheduleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_PlaylistSyncRun_JobSchedule");
});
modelBuilder.Entity("allstarr.Core.Storage.PlaylistTargetMembershipRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlaylistSyncRunRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CreatedBySyncRunId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistTargetMembership_PlaylistSyncRun");
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
.WithMany()
.HasForeignKey("TenantId", "LibraryTrackId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistTargetMembership_LibraryTrack");
b.HasOne("allstarr.Core.Storage.PlaylistLinkRecord", null)
.WithMany()
.HasForeignKey("TenantId", "PlaylistLinkId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_PlaylistTargetMembership_PlaylistLink");
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderAccountRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("FK_provider_account_creator");
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
.WithMany()
.HasForeignKey("SecretReferenceId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_provider_account_tenant_owner");
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderCircuitRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthRollupRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthSampleRecord", b =>
{
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.ProviderTrackIdentityRecord", b =>
{
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
.WithMany()
.HasForeignKey("ProviderAccountId", "ProviderId")
.HasPrincipalKey("Id", "ProviderId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_track_identity_provider_account");
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_track_identity_canonical_recording");
});
modelBuilder.Entity("allstarr.Core.Storage.SecretReferenceRecord", b =>
{
b.HasOne("allstarr.Core.Storage.BackendIdentityRecord", null)
.WithMany()
.HasForeignKey("BackendIdentityId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.SecretVersionRecord", b =>
{
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
.WithMany()
.HasForeignKey("SecretReferenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.TrackMatchRecord", b =>
{
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
.WithMany()
.HasForeignKey("TenantId", "CanonicalRecordingId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_TrackMatch_Canonicaling");
b.HasOne("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", null)
.WithMany()
.HasForeignKey("TenantId", "ExternalSnapshotId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_TrackMatch_ExternalMetadataSnapshot");
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
.WithMany()
.HasForeignKey("TenantId", "LibraryTrackId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_TrackMatch_LibraryTrack");
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
.WithMany()
.HasForeignKey("TenantId", "OwnerUserId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("FK_TrackMatch_PlatformUser");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace allstarr.Core.Storage.Migrations
{
/// <inheritdoc />
public partial class AddAdminOidcLinks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "admin_oidc_links",
columns: table => new
{
Id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
BackendIdentityId = table.Column<Guid>(type: "uuid", nullable: false),
SecretReferenceId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_oidc_links", x => x.Id);
table.ForeignKey(
name: "FK_admin_oidc_links_backend_identities_BackendIdentityId",
column: x => x.BackendIdentityId,
principalTable: "backend_identities",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_admin_oidc_links_secret_references_SecretReferenceId",
column: x => x.SecretReferenceId,
principalTable: "secret_references",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_admin_oidc_links_BackendIdentityId",
table: "admin_oidc_links",
column: "BackendIdentityId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_admin_oidc_links_SecretReferenceId",
table: "admin_oidc_links",
column: "SecretReferenceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "admin_oidc_links");
}
}
}
@@ -1906,6 +1906,31 @@ namespace allstarr.Core.Storage.Migrations
b.ToTable("admin_auth_sessions", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.AdminOidcLinkRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("BackendIdentityId")
.HasColumnType("uuid");
b.Property<long>("CreatedAt")
.HasColumnType("bigint");
b.Property<Guid>("SecretReferenceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("BackendIdentityId")
.IsUnique();
b.HasIndex("SecretReferenceId");
b.ToTable("admin_oidc_links", (string)null);
});
modelBuilder.Entity("allstarr.Core.Storage.ApplicationCacheEntryRecord", b =>
{
b.Property<string>("Key")
@@ -5243,6 +5268,21 @@ namespace allstarr.Core.Storage.Migrations
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("allstarr.Core.Storage.AdminOidcLinkRecord", b =>
{
b.HasOne("allstarr.Core.Storage.BackendIdentityRecord", null)
.WithMany()
.HasForeignKey("BackendIdentityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
.WithMany()
.HasForeignKey("SecretReferenceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("allstarr.Core.Storage.AuditEventRecord", b =>
{
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
@@ -779,13 +779,15 @@ public sealed class SelectiveStateTransferService
}
case "secret-references":
{
var rows = await context.SecretReferences.AsNoTracking().ToListAsync(cancellationToken);
var rows = await context.SecretReferences.AsNoTracking()
.Where(item => item.Purpose != AdminOidcLinkRecord.SecretPurpose).ToListAsync(cancellationToken);
await WriteJsonAsync(archive, entry, rows, cancellationToken);
return rows.Count;
}
case "secret-versions":
{
var rows = await context.SecretVersions.AsNoTracking().ToListAsync(cancellationToken);
var rows = await context.SecretVersions.AsNoTracking().Where(item => context.SecretReferences.Any(
secret => secret.Id == item.SecretReferenceId && secret.Purpose != AdminOidcLinkRecord.SecretPurpose)).ToListAsync(cancellationToken);
await WriteJsonAsync(archive, entry, rows, cancellationToken);
return rows.Count;
}
@@ -53,7 +53,7 @@ public class AdminAuthenticationMiddleware
var session = await _sessionService.GetValidSessionAsync(context.Request, context.RequestAborted);
if (session is null)
{
DeleteSessionCookies(context.Response);
AdminSessionCookies.Delete(context);
await WriteUnauthorizedResponse(context);
return;
}
@@ -195,13 +195,6 @@ public class AdminAuthenticationMiddleware
}));
}
private static void DeleteSessionCookies(HttpResponse response)
{
response.Cookies.Delete(AdminAuthSessionService.SessionCookieName, new CookieOptions { Path = "/" });
response.Cookies.Delete(AdminAuthSessionService.LegacySessionCookieName, new CookieOptions { Path = "/" });
response.Cookies.Delete(AdminAuthSessionService.LegacySessionCookieName, new CookieOptions { Path = "/api/admin/auth" });
}
private async Task WriteForbiddenResponse(HttpContext context)
{
_logger.LogDebug("AdminAuthenticationMiddleware rejected unauthorized request to {Path}",
@@ -0,0 +1,59 @@
using allstarr.Services.Admin;
namespace allstarr.Middleware;
/// <summary>
/// Applies the configured admin URL prefix on the local admin listener.
/// Reverse proxies may either preserve the prefix or remove it before
/// forwarding; both forms are normalized to the same request path for the
/// rest of the pipeline.
/// </summary>
public sealed class AdminBasePathMiddleware
{
private const int AdminPort = 5275;
private readonly RequestDelegate _next;
private readonly AdminBasePath _basePath;
public AdminBasePathMiddleware(RequestDelegate next, AdminBasePath basePath)
{
_next = next;
_basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Connection.LocalPort != AdminPort || _basePath.Value.Length == 0)
{
await _next(context);
return;
}
var configuredPrefix = _basePath.Value;
var requestPath = context.Request.Path.Value ?? "/";
if (string.Equals(requestPath, configuredPrefix, StringComparison.Ordinal))
{
// A path base without its trailing slash is ambiguous to relative
// asset URLs. Keep the query string while making the canonical URL.
context.Response.StatusCode = StatusCodes.Status308PermanentRedirect;
context.Response.Headers.Location = configuredPrefix + "/" + context.Request.QueryString;
return;
}
if (requestPath.StartsWith(configuredPrefix + "/", StringComparison.Ordinal))
{
context.Request.PathBase = configuredPrefix;
context.Request.Path = requestPath[configuredPrefix.Length..];
}
else
{
// The proxy stripped the prefix before forwarding this request.
// Restore it in PathBase so generated URLs and cookie paths retain
// the public mount point without changing the route path.
context.Request.PathBase = configuredPrefix;
}
await _next(context);
}
}
@@ -1,3 +1,8 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using allstarr.Services.Admin;
namespace allstarr.Middleware;
/// <summary>
@@ -6,18 +11,43 @@ namespace allstarr.Middleware;
/// </summary>
public class AdminStaticFilesMiddleware
{
private const long MaxIndexBytes = 4 * 1024 * 1024;
private readonly RequestDelegate _next;
private readonly IWebHostEnvironment _env;
private readonly string _adminBasePath;
private const int AdminPort = 5275;
private readonly string _webRootPath;
private readonly string _webRootPathWithSeparator;
private static readonly Regex BasePathMetaTag = new(
@"<meta\b(?=[^>]*\bname\s*=\s*[""']allstarr-base-path[""'])[^>]*>",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex MetaContentAttribute = new(
@"(?<prefix>\bcontent\s*=\s*)(?<quote>[""'])(?<value>.*?)\k<quote>",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex GeneratedAssetUrl = new(
@"(?<quote>[""'])/(?<asset>_app/|favicon\.svg(?=[?#""']))",
RegexOptions.Compiled);
private static readonly Regex InlineScript = new(
@"<script\b(?<attributes>[^>]*)>(?<body>.*?)</script\s*>",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex CspMetaTag = new(
@"<meta\b(?=[^>]*\bhttp-equiv\s*=\s*[""']content-security-policy[""'])[^>]*>",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex CspContentAttribute = new(
@"(?<prefix>\bcontent\s*=\s*)(?<quote>[""'])(?<value>.*?)\k<quote>",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex CspScriptDirective = new(
@"(?<directive>\bscript-src\b)(?<spacing>\s+)(?<sources>[^;]*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public AdminStaticFilesMiddleware(
RequestDelegate next,
IWebHostEnvironment env)
IWebHostEnvironment env,
AdminBasePath? basePath = null)
{
_next = next;
_env = env;
_adminBasePath = basePath?.Value ?? string.Empty;
var webRoot = string.IsNullOrWhiteSpace(_env.WebRootPath)
? Path.Combine(_env.ContentRootPath, "wwwroot")
: _env.WebRootPath;
@@ -48,7 +78,24 @@ public class AdminStaticFilesMiddleware
{
SetRevalidationHeaders(context.Response);
context.Response.ContentType = "text/html";
await context.Response.SendFileAsync(indexPath);
if (_adminBasePath.Length == 0)
{
await context.Response.SendFileAsync(indexPath);
}
else
{
var html = await ReadBoundedTextAsync(indexPath, context.RequestAborted);
if (html is null)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
return;
}
var transformed = TransformIndexHtml(html, _adminBasePath);
context.Response.ContentLength = Encoding.UTF8.GetByteCount(transformed);
if (HttpMethods.IsGet(context.Request.Method))
await context.Response.WriteAsync(transformed, Encoding.UTF8, context.RequestAborted);
}
return;
}
}
@@ -83,6 +130,113 @@ public class AdminStaticFilesMiddleware
await _next(context);
}
internal static string TransformIndexHtml(string html, string adminBasePath)
{
ArgumentNullException.ThrowIfNull(html);
ArgumentException.ThrowIfNullOrEmpty(adminBasePath);
var transformed = BasePathMetaTag.Replace(
html,
match => MetaContentAttribute.Replace(
match.Value,
content => $"{content.Groups["prefix"].Value}{content.Groups["quote"].Value}{adminBasePath}{content.Groups["quote"].Value}",
1),
1);
transformed = GeneratedAssetUrl.Replace(
transformed,
match => $"{match.Groups["quote"].Value}{adminBasePath}/{match.Groups["asset"].Value}");
return ReplaceChangedInlineScriptHashes(html, transformed);
}
private static async Task<string?> ReadBoundedTextAsync(string path, CancellationToken cancellationToken)
{
var length = new FileInfo(path).Length;
if (length > MaxIndexBytes)
return null;
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 16 * 1024,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
using var reader = new StreamReader(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), detectEncodingFromByteOrderMarks: true);
var builder = new StringBuilder((int)Math.Min(length, MaxIndexBytes));
var buffer = new char[16 * 1024];
var characterLimit = (int)MaxIndexBytes;
while (true)
{
var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken);
if (read == 0)
break;
if (builder.Length > characterLimit - read)
return null;
builder.Append(buffer, 0, read);
}
return builder.ToString();
}
private static string ReplaceChangedInlineScriptHashes(string original, string transformed)
{
var originalScripts = InlineScript.Matches(original)
.Cast<Match>()
.Where(match => !HasScriptSource(match.Groups["attributes"].Value))
.Select(match => match.Groups["body"].Value)
.ToArray();
var transformedScripts = InlineScript.Matches(transformed)
.Cast<Match>()
.Where(match => !HasScriptSource(match.Groups["attributes"].Value))
.Select(match => match.Groups["body"].Value)
.ToArray();
var changed = new List<(string OldHash, string NewHash)>();
for (var index = 0; index < Math.Min(originalScripts.Length, transformedScripts.Length); index++)
{
if (string.Equals(originalScripts[index], transformedScripts[index], StringComparison.Ordinal))
continue;
changed.Add((Sha256Base64(originalScripts[index]), Sha256Base64(transformedScripts[index])));
}
if (changed.Count == 0)
return transformed;
return CspMetaTag.Replace(
transformed,
meta => CspContentAttribute.Replace(
meta.Value,
content =>
{
var csp = content.Groups["value"].Value;
csp = CspScriptDirective.Replace(csp, directive =>
{
var sources = directive.Groups["sources"].Value;
foreach (var (oldHash, newHash) in changed)
{
var oldToken = $"'sha256-{oldHash}'";
var newToken = $"'sha256-{newHash}'";
if (sources.Contains(oldToken, StringComparison.Ordinal))
sources = sources.Replace(oldToken, newToken, StringComparison.Ordinal);
}
return $"{directive.Groups["directive"].Value}{directive.Groups["spacing"].Value}{sources}";
},
1);
return $"{content.Groups["prefix"].Value}{content.Groups["quote"].Value}{csp}{content.Groups["quote"].Value}";
},
1),
1);
}
private static bool HasScriptSource(string attributes) =>
Regex.IsMatch(attributes, @"\bsrc\s*=", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static string Sha256Base64(string value) =>
Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
private static void SetRevalidationHeaders(HttpResponse response)
{
// Entry HTML and shared static media must revalidate across container updates.
+7
View File
@@ -10,6 +10,7 @@ using allstarr.Services.Subsonic;
using allstarr.Core.Protocols.Subsonic;
using allstarr.Services.Jellyfin;
using allstarr.Services.Common;
using allstarr.Services.Admin;
using allstarr.Services.Lyrics;
using allstarr.Services.Scrobbling;
using allstarr.Services.Spotify;
@@ -313,6 +314,8 @@ builder.Services.AddScoped<allstarr.Filters.AdminPortFilter>();
builder.Services.AddSingleton<allstarr.Services.Admin.AdminHelperService>();
builder.Services.AddSingleton<allstarr.Services.Admin.IAdminAuthSessionStore, allstarr.Services.Admin.EfAdminAuthSessionStore>();
builder.Services.AddSingleton<allstarr.Services.Admin.AdminAuthSessionService>();
builder.Services.AddSingleton<allstarr.Services.Admin.AdminBasePath>();
builder.Services.AddAdminOidc(builder.Configuration);
builder.Services.AddSingleton<allstarr.Services.Admin.AdminProtocolExecutionContextFactory>();
builder.Services.AddSingleton<allstarr.Services.Admin.AdminUpdateFeed>();
@@ -585,6 +588,10 @@ if (builder.Configuration.GetValue<bool>("HttpsRedirection:Enabled"))
// Keep admin assets and authentication on the admin listener.
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
app.UseMiddleware<allstarr.Middleware.AdminBasePathMiddleware>();
// Select endpoints after stripping the admin prefix, before authentication.
app.UseRouting();
app.UseAuthentication();
app.UseMiddleware<allstarr.Middleware.AdminStaticFilesMiddleware>();
app.UseMiddleware<allstarr.Middleware.AdminAuthenticationMiddleware>();
@@ -15,6 +15,9 @@ public sealed class AdminAuthSession
public string BackendType { get; init; } = "Jellyfin";
public Guid? TenantId { get; init; }
public Guid? AllstarrUserId { get; init; }
public Guid? OidcSecretReferenceId { get; init; }
public string? OidcBackendEndpoint { get; init; }
public string? OidcBackendInstanceId { get; init; }
public required string JellyfinAccessToken { get; init; }
public string? JellyfinServerId { get; init; }
public bool IsPersistent { get; init; }
@@ -74,7 +77,11 @@ public sealed class EfAdminAuthSessionStore(IDbContextFactory<AllstarrDbContext>
public sealed class AdminAuthSessionService(
IAdminAuthSessionStore store,
IDataProtectionProvider dataProtectionProvider,
ILogger<AdminAuthSessionService> logger)
ILogger<AdminAuthSessionService> logger,
IDbContextFactory<AllstarrDbContext>? contextFactory = null,
AdminOidcOptions? oidcOptions = null,
AdminOidcBackendAuthentication? oidcBackend = null,
allstarr.Core.Identity.IdentityOptions? identityOptions = null)
{
public const string SessionCookieName = "allstarr_admin_session_v3";
public const string LegacySessionCookieName = "allstarr_admin_session";
@@ -97,7 +104,10 @@ public sealed class AdminAuthSessionService(
string backendType = "Jellyfin",
Guid? tenantId = null,
Guid? allstarrUserId = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
Guid? oidcSecretReferenceId = null,
string? oidcBackendEndpoint = null,
string? oidcBackendInstanceId = null)
{
var now = DateTime.UtcNow;
var session = new AdminAuthSession
@@ -109,6 +119,9 @@ public sealed class AdminAuthSessionService(
BackendType = backendType,
TenantId = tenantId,
AllstarrUserId = allstarrUserId,
OidcSecretReferenceId = oidcSecretReferenceId,
OidcBackendEndpoint = oidcBackendEndpoint,
OidcBackendInstanceId = oidcBackendInstanceId,
JellyfinAccessToken = jellyfinAccessToken,
JellyfinServerId = jellyfinServerId,
IsPersistent = isPersistent,
@@ -157,6 +170,27 @@ public sealed class AdminAuthSessionService(
}
var now = DateTime.UtcNow;
if (session.OidcSecretReferenceId is { } secretId)
{
if (oidcOptions?.Enabled != true || contextFactory == null || oidcBackend == null ||
!session.BackendType.Equals(oidcBackend.Backend, StringComparison.OrdinalIgnoreCase) || session.OidcBackendEndpoint != oidcBackend.Endpoint ||
session.OidcBackendInstanceId != identityOptions?.BackendInstanceId) return null;
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await (from link in db.AdminOidcLinks
join identity in db.BackendIdentities on link.BackendIdentityId equals identity.Id
join secret in db.SecretReferences on link.SecretReferenceId equals secret.Id
where secret.Id == secretId && secret.RevokedAt == null &&
secret.BackendIdentityId == identity.Id && secret.TenantId == session.TenantId &&
secret.Purpose == AdminOidcLinkRecord.SecretPurpose &&
identity.TenantId == session.TenantId && identity.UserId == session.AllstarrUserId &&
identity.BackendType == oidcBackend.Backend && identity.BackendInstanceId == session.OidcBackendInstanceId &&
identity.PrincipalId == session.UserId
select link).AnyAsync(cancellationToken))
{
await store.RemoveAsync(sessionId, cancellationToken);
return null;
}
}
session.LastSeenUtc = now;
if (record.LastSeenAt <= DateTimeOffset.UtcNow.AddMinutes(-5))
{
+65
View File
@@ -0,0 +1,65 @@
using Microsoft.Extensions.Configuration;
namespace allstarr.Services.Admin;
/// <summary>
/// The optional URL path prefix used to publish the local admin surface.
/// </summary>
public sealed class AdminBasePath
{
public const string ConfigurationKey = "Admin:BasePath";
public AdminBasePath(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
Value = Normalize(configuration[ConfigurationKey]);
}
public string Value { get; }
/// <summary>
/// Normalizes an admin prefix to either the empty string or a slash-prefixed
/// path made up of safe ASCII segments.
/// </summary>
public static string Normalize(string? configured)
{
if (string.IsNullOrEmpty(configured))
return string.Empty;
if (configured != configured.Trim())
throw Invalid(configured);
// A single slash is the same as the unprefixed admin surface. It is
// accepted so an explicitly configured root does not fail startup.
if (configured == "/")
return string.Empty;
if (!configured.StartsWith("/", StringComparison.Ordinal) ||
configured.StartsWith("//", StringComparison.Ordinal))
throw Invalid(configured);
var value = configured.EndsWith("/", StringComparison.Ordinal)
? configured[..^1]
: configured;
if (value.Length == 0 || value.Contains("//", StringComparison.Ordinal))
throw Invalid(configured);
var segments = value[1..].Split('/');
if (segments.Any(segment => segment.Length == 0 || !IsSafeSegment(segment)))
throw Invalid(configured);
return value;
}
private static bool IsSafeSegment(string segment) =>
segment.All(character =>
character is >= 'a' and <= 'z' or
>= 'A' and <= 'Z' or
>= '0' and <= '9' or
'-' or '_');
private static ArgumentException Invalid(string value) =>
new(
$"Configuration '{ConfigurationKey}' must be empty or a slash-prefixed path containing only ASCII alphanumeric, hyphen, and underscore segments; received '{value}'.",
ConfigurationKey);
}
@@ -0,0 +1,81 @@
using System.Text.Json;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Admin;
public sealed record AdminOidcCredential(string Backend, string Endpoint, string UserId, string Credential);
public sealed record AdminOidcBackendUser(string UserId, string Name, bool IsAdministrator, string AccessToken);
public sealed class AdminOidcBackendAuthentication(
IConfiguration configuration,
IOptions<JellyfinSettings> jellyfin,
IOptions<SubsonicSettings> subsonic,
IHttpClientFactory clients)
{
public string Backend => string.Equals(configuration["Backend:Type"], "Subsonic", StringComparison.OrdinalIgnoreCase)
? "subsonic" : "jellyfin";
public string Endpoint => (Backend == "subsonic" ? subsonic.Value.Url : jellyfin.Value.Url)?.TrimEnd('/') ?? "";
public async Task<AdminOidcBackendUser?> ValidateAsync(AdminOidcCredential credential, CancellationToken cancellationToken)
{
if (credential.Backend != Backend || credential.Endpoint != Endpoint ||
string.IsNullOrEmpty(Endpoint) || string.IsNullOrEmpty(credential.Credential)) return null;
using var request = Backend == "jellyfin"
? new HttpRequestMessage(HttpMethod.Get, Endpoint + "/Users/Me")
: new HttpRequestMessage(HttpMethod.Post, Endpoint + "/rest/getUser.view")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["u"] = credential.UserId,
["p"] = credential.Credential,
["username"] = credential.UserId,
["v"] = "1.16.1",
["c"] = "allstarr-admin",
["f"] = "json"
})
};
if (Backend == "jellyfin")
request.Headers.TryAddWithoutValidation("Authorization", AuthHeaderHelper.CreateAuthHeader(credential.Credential));
using var client = clients.CreateClient();
client.Timeout = TimeSpan.FromSeconds(15);
using var response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
using var document = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = document.RootElement;
if (Backend == "subsonic")
{
return AdminBackendIdentity.TryReadSubsonic(root, credential.UserId, out var user)
? user : null;
}
if (!root.TryGetProperty("Id", out var id) || id.GetString() != credential.UserId ||
!root.TryGetProperty("Name", out var name) || string.IsNullOrEmpty(name.GetString()) ||
!root.TryGetProperty("Policy", out var policy) ||
!policy.TryGetProperty("IsDisabled", out var disabled) || disabled.ValueKind != JsonValueKind.False)
return null;
return new(credential.UserId, name.GetString()!,
policy.TryGetProperty("IsAdministrator", out var admin) && admin.ValueKind == JsonValueKind.True,
credential.Credential);
}
}
internal static class AdminBackendIdentity
{
public static bool TryReadSubsonic(JsonElement root, string username, out AdminOidcBackendUser identity,
bool allowMissingUserName = false)
{
identity = null!;
if (!root.TryGetProperty("subsonic-response", out var envelope) ||
!envelope.TryGetProperty("status", out var status) || !string.Equals(status.GetString(), "ok", StringComparison.OrdinalIgnoreCase) ||
!envelope.TryGetProperty("user", out var user)) return false;
var name = user.TryGetProperty("username", out var returnedName) ? returnedName.GetString() : allowMissingUserName ? username : null;
if (string.IsNullOrWhiteSpace(name) || !string.Equals(name, username, StringComparison.OrdinalIgnoreCase)) return false;
identity = new(name, name,
user.TryGetProperty("adminRole", out var admin) && admin.ValueKind == JsonValueKind.True, "");
return true;
}
}
+121
View File
@@ -0,0 +1,121 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using allstarr.Core.Identity;
using allstarr.Core.Secrets;
using allstarr.Core.Storage;
using Microsoft.EntityFrameworkCore;
namespace allstarr.Services.Admin;
public sealed class AdminOidcLinks(
IDbContextFactory<AllstarrDbContext> factory,
EncryptedSecretStore secrets,
IdentityOptions identityOptions,
AdminOidcBackendAuthentication backend,
BackendIdentityResolver identities,
AdminAuthSessionService sessions)
{
public const string SecretPurpose = AdminOidcLinkRecord.SecretPurpose;
public static string? IdentityKey(ClaimsPrincipal principal, string clientId)
{
var issuer = principal.FindFirst("iss")?.Value;
var subject = principal.FindFirst("sub")?.Value;
if (principal.Identity?.IsAuthenticated != true || string.IsNullOrEmpty(issuer) || string.IsNullOrEmpty(subject))
return null;
return Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new[] { issuer, subject, clientId })));
}
public async Task LinkAsync(string key, AllstarrPrincipal principal, AdminOidcCredential credential,
CancellationToken cancellationToken)
{
await using var db = await factory.CreateDbContextAsync(cancellationToken);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var identity = await db.BackendIdentities.SingleAsync(item =>
item.TenantId == principal.TenantId && item.UserId == principal.UserId &&
item.BackendType == principal.BackendType && item.BackendInstanceId == principal.BackendInstanceId &&
item.PrincipalId == principal.BackendPrincipalId, cancellationToken);
var existing = await db.AdminOidcLinks.SingleOrDefaultAsync(item => item.Id == key, cancellationToken);
if (existing != null && existing.BackendIdentityId != identity.Id ||
await db.AdminOidcLinks.AnyAsync(item => item.BackendIdentityId == identity.Id && item.Id != key, cancellationToken))
throw new UnauthorizedAccessException("An account is already linked. Disconnect it before linking another account.");
if (credential.Backend != identity.BackendType || credential.UserId != identity.PrincipalId ||
credential.Endpoint != backend.Endpoint) throw new UnauthorizedAccessException("Backend identity changed.");
var payload = JsonSerializer.SerializeToUtf8Bytes(credential);
try
{
var secret = await secrets.StoreWithinTransactionAsync(db, identity.TenantId, SecretPurpose,
payload, existing?.SecretReferenceId, cancellationToken);
db.SecretReferences.Local.Single(item => item.Id == secret.Id).BackendIdentityId = identity.Id;
if (existing == null) db.AdminOidcLinks.Add(new()
{
Id = key,
BackendIdentityId = identity.Id,
SecretReferenceId = secret.Id,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
finally { CryptographicOperations.ZeroMemory(payload); }
}
public async Task<AdminAuthSession?> SignInAsync(string key, CancellationToken cancellationToken)
{
await using var db = await factory.CreateDbContextAsync(cancellationToken);
var binding = await (from link in db.AdminOidcLinks
join identity in db.BackendIdentities on link.BackendIdentityId equals identity.Id
join secret in db.SecretReferences on link.SecretReferenceId equals secret.Id
where link.Id == key && secret.RevokedAt == null &&
secret.BackendIdentityId == identity.Id && secret.TenantId == identity.TenantId &&
secret.Purpose == SecretPurpose && identity.BackendType == backend.Backend &&
identity.BackendInstanceId == identityOptions.BackendInstanceId
select new { link, identity }).SingleOrDefaultAsync(cancellationToken);
if (binding == null) return null;
using var lease = await secrets.OpenAsync(binding.link.SecretReferenceId,
new(binding.identity.TenantId), cancellationToken);
var credential = JsonSerializer.Deserialize<AdminOidcCredential>(lease.Value.Span);
if (credential == null || credential.UserId != binding.identity.PrincipalId) return null;
var user = await backend.ValidateAsync(credential, cancellationToken);
if (user == null) return null;
var principal = await identities.ResolveAsync(new(backend.Backend, user.UserId, user.Name, user.IsAdministrator), cancellationToken);
if (principal == null || principal.TenantId != binding.identity.TenantId || principal.UserId != binding.identity.UserId ||
principal.BackendType != binding.identity.BackendType || principal.BackendInstanceId != binding.identity.BackendInstanceId ||
principal.BackendPrincipalId != binding.identity.PrincipalId)
return null;
return await sessions.CreateSessionAsync(user.UserId, user.Name, user.IsAdministrator, user.AccessToken, null,
backendType: backend.Backend == "jellyfin" ? "Jellyfin" : "Subsonic", tenantId: principal.TenantId, allstarrUserId: principal.UserId,
cancellationToken: cancellationToken, oidcSecretReferenceId: binding.link.SecretReferenceId,
oidcBackendEndpoint: backend.Endpoint, oidcBackendInstanceId: identityOptions.BackendInstanceId);
}
public async Task<bool> IsLinkedAsync(AdminAuthSession session, CancellationToken cancellationToken)
{
await using var db = await factory.CreateDbContextAsync(cancellationToken);
return await OwnedLinks(db, session).AnyAsync(cancellationToken);
}
public async Task UnlinkAsync(AdminAuthSession session, CancellationToken cancellationToken)
{
await using var db = await factory.CreateDbContextAsync(cancellationToken);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var link in await OwnedLinks(db, session).ToListAsync(cancellationToken))
{
var secret = await db.SecretReferences.SingleAsync(item => item.Id == link.SecretReferenceId, cancellationToken);
secret.RevokedAt = DateTimeOffset.UtcNow;
db.AdminOidcLinks.Remove(link);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
private IQueryable<AdminOidcLinkRecord> OwnedLinks(AllstarrDbContext db, AdminAuthSession session) =>
from link in db.AdminOidcLinks
join identity in db.BackendIdentities on link.BackendIdentityId equals identity.Id
where identity.TenantId == session.TenantId && identity.UserId == session.AllstarrUserId &&
identity.PrincipalId == session.UserId && identity.BackendType == backend.Backend &&
identity.BackendInstanceId == identityOptions.BackendInstanceId
select link;
}
+118
View File
@@ -0,0 +1,118 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace allstarr.Services.Admin;
public sealed class AdminOidcOptions
{
public const string Scheme = "admin-oidc";
public const string PendingScheme = "admin-oidc-pending";
public const string CallbackPath = "/api/admin/auth/oidc/callback";
public bool Enabled { get; set; }
public string Authority { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string PublicUrl { get; set; } = "";
public string DisplayName { get; set; } = "Single sign-on";
public void Validate(string basePath)
{
if (!Enabled) return;
if (!IsHttpsUrl(Authority, out _) || !IsHttpsUrl(PublicUrl, out var url) ||
url!.AbsolutePath.TrimEnd('/') != basePath || string.IsNullOrWhiteSpace(ClientId) ||
string.IsNullOrWhiteSpace(ClientSecret) || string.IsNullOrWhiteSpace(DisplayName))
throw new InvalidOperationException(
"Admin OIDC requires HTTPS Authority and PublicUrl, ClientId, ClientSecret and DisplayName. " +
"PublicUrl must end at Admin:BasePath, without query or fragment.");
PublicUrl = PublicUrl.TrimEnd('/');
}
private static bool IsHttpsUrl(string value, out Uri? url) =>
Uri.TryCreate(value, UriKind.Absolute, out url) && url.Scheme == "https" &&
string.IsNullOrEmpty(url.UserInfo) && string.IsNullOrEmpty(url.Query) && string.IsNullOrEmpty(url.Fragment);
}
public static class AdminOidcRegistration
{
public static IServiceCollection AddAdminOidc(this IServiceCollection services, IConfiguration configuration)
{
var settings = configuration.GetSection("Admin:Oidc").Get<AdminOidcOptions>() ?? new();
var basePath = new AdminBasePath(configuration).Value;
settings.Validate(basePath);
services.AddSingleton(settings);
services.AddSingleton<AdminOidcLinks>();
services.AddSingleton<AdminOidcBackendAuthentication>();
services.AddAntiforgery(options =>
{
options.HeaderName = "X-Allstarr-CSRF";
options.Cookie.Name = "allstarr_admin_csrf";
options.Cookie.Path = string.IsNullOrEmpty(basePath) ? "/" : basePath;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = settings.Enabled ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
});
var authentication = services.AddAuthentication();
if (!settings.Enabled) return services;
authentication.AddCookie(AdminOidcOptions.PendingScheme, options =>
{
options.Cookie.Name = "allstarr_oidc_pending";
options.Cookie.Path = string.IsNullOrEmpty(basePath) ? "/" : basePath;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
options.SlidingExpiration = false;
}).AddOpenIdConnect(AdminOidcOptions.Scheme, options =>
{
options.SignInScheme = AdminOidcOptions.PendingScheme;
options.Authority = settings.Authority;
options.ClientId = settings.ClientId;
options.ClientSecret = settings.ClientSecret;
options.CallbackPath = AdminOidcOptions.CallbackPath;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.Query;
options.UsePkce = true;
options.MapInboundClaims = false;
options.ClaimActions.Clear();
options.SaveTokens = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.CorrelationCookie.Path = basePath + AdminOidcOptions.CallbackPath;
options.NonceCookie.Path = basePath + AdminOidcOptions.CallbackPath;
options.Events = new OpenIdConnectEvents
{
OnMessageReceived = context =>
{
if (context.HttpContext.Connection.LocalPort != 5275) context.SkipHandler();
else if (!context.Request.IsHttps) context.Fail("OIDC requires a trusted HTTPS request.");
return Task.CompletedTask;
},
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.RedirectUri = settings.PublicUrl + AdminOidcOptions.CallbackPath;
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
context.Properties ??= new Microsoft.AspNetCore.Authentication.AuthenticationProperties();
context.Properties.IssuedUtc = DateTimeOffset.UtcNow;
context.Properties.ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(5);
var subject = context.Principal?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(subject)) context.Fail("Missing subject.");
else context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("iss", context.SecurityToken.Issuer), new Claim("sub", subject)], AdminOidcOptions.Scheme));
return Task.CompletedTask;
},
OnRemoteFailure = context =>
{
context.HandleResponse();
context.Response.Redirect(settings.PublicUrl + "/?oidc=failed");
return Task.CompletedTask;
}
};
});
return services;
}
}
@@ -0,0 +1,29 @@
namespace allstarr.Services.Admin;
internal static class AdminSessionCookies
{
public static string Path(HttpRequest request) => request.PathBase.HasValue ? request.PathBase.Value! : "/";
public static void Write(HttpContext context, AdminAuthSession session) =>
Write(context, session.SessionId, session.ExpiresAtUtc);
public static void Write(HttpContext context, string sessionId, DateTime expiresAtUtc) =>
context.Response.Cookies.Append(AdminAuthSessionService.SessionCookieName, sessionId, new CookieOptions
{
HttpOnly = true,
Secure = context.Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = Path(context.Request),
IsEssential = true,
Expires = expiresAtUtc
});
public static void Delete(HttpContext context)
{
var path = Path(context.Request);
context.Response.Cookies.Delete(AdminAuthSessionService.SessionCookieName, new CookieOptions { Path = path });
context.Response.Cookies.Delete(AdminAuthSessionService.LegacySessionCookieName, new CookieOptions { Path = path });
context.Response.Cookies.Delete(AdminAuthSessionService.LegacySessionCookieName,
new CookieOptions { Path = context.Request.PathBase + "/api/admin/auth" });
}
}
@@ -23,6 +23,13 @@ public static class RuntimeEnvConfiguration
["BACKEND_TYPE"] = ["Backend:Type"],
["ALLSTARR_RELEASE_PROFILE"] = ["Release:Profile"],
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
["ADMIN_BASE_PATH"] = ["Admin:BasePath"],
["ADMIN_OIDC_ENABLED"] = ["Admin:Oidc:Enabled"],
["ADMIN_OIDC_AUTHORITY"] = ["Admin:Oidc:Authority"],
["ADMIN_OIDC_CLIENT_ID"] = ["Admin:Oidc:ClientId"],
["ADMIN_OIDC_CLIENT_SECRET"] = ["Admin:Oidc:ClientSecret"],
["ADMIN_OIDC_PUBLIC_URL"] = ["Admin:Oidc:PublicUrl"],
["ADMIN_OIDC_DISPLAY_NAME"] = ["Admin:Oidc:DisplayName"],
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
+1
View File
@@ -24,6 +24,7 @@
<PackageReference Include="Cronos" Version="0.11.1" />
<PackageReference Include="Jint" Version="4.11.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
<!-- Keep the OpenAPI object model on the current compatible 2.x security line used by Swashbuckle. -->
<PackageReference Include="Microsoft.OpenApi" Version="2.10.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
+1
View File
@@ -16,6 +16,7 @@ User and operator guides describe shipped behavior. Contributor assessments expl
## Operator guides
- [WebUI proxy and SSO setup](operations/webui-access.md)
- [Legacy `.env` import](operations/legacy-env-import.md)
- [Apple download provider](operations/apple-download-provider.md)
- [Spotify lyrics service](operations/spotify-lyrics-sidecar.md)
+4
View File
@@ -14,6 +14,8 @@ Allstarr separates deployment bootstrap, durable application settings, and encry
| Image and media mounts | `ALLSTARR_IMAGE`, `DOWNLOAD_PATH`, `KEPT_PATH`, `APPLE_UPLOAD_PATH` |
| Public listeners | `PROXY_BIND_ADDRESS`, `PROXY_PORT`, `ADMIN_BIND_ADDRESS`, `ADMIN_PORT` |
| Admin network policy | `ADMIN_BIND_ANY_IP`, `ADMIN_TRUSTED_SUBNETS` |
| Admin URL prefix | `ADMIN_BASE_PATH` (empty by default) |
| WebUI SSO | `ADMIN_OIDC_ENABLED`, `ADMIN_OIDC_AUTHORITY`, `ADMIN_OIDC_CLIENT_ID`, `ADMIN_OIDC_CLIENT_SECRET`, `ADMIN_OIDC_PUBLIC_URL`, `ADMIN_OIDC_DISPLAY_NAME` |
| Extension install policy | `EXTENSIONS_ALLOW_REMOTE_INSTALL` |
| Browser origin policy | `CORS_ALLOWED_ORIGINS`, `CORS_ALLOW_CREDENTIALS` |
| Optional Spotify lyrics bootstrap | `SPOTIFY_API_SESSION_COOKIE` |
@@ -23,6 +25,8 @@ The Compose file translates these values into ASP.NET configuration. Changing on
PostgreSQL is mandatory. There is no SQLite, Redis, or Valkey runtime option.
For a dashboard mounted under a reverse-proxy path or optional OIDC login, see [WebUI proxy and SSO setup](webui-access.md). Neither setting changes music-client authentication on port 5274.
## Protected files
`allstarr.sh init` creates the PostgreSQL password file and Allstarr key ring with private permissions. Back them up separately from the database.
+76
View File
@@ -0,0 +1,76 @@
# WebUI proxy and SSO setup
The dashboard uses port 5275 and remains loopback-only by default. URL prefixes and OpenID Connect (OIDC) login are opt-in. Music clients keep using port 5274 with their media-server credentials.
## Reverse-proxy path
For `https://media.example.org/allstarr/`, set:
```dotenv
ADMIN_BASE_PATH=/allstarr
```
Recreate the container after changing this value. Prefixes contain slash-separated letters, digits, hyphens, or underscores. Leave the value empty to serve the dashboard at `/`.
Your proxy may preserve `/allstarr/` or strip it before forwarding to port 5275. Allstarr applies the configured prefix to assets, API requests, live updates, downloads, and session cookies. It ignores `X-Forwarded-Prefix`; a request cannot choose its own mount point. Redirect `/allstarr` to `/allstarr/` at the proxy if it strips the prefix.
An nginx location that strips the prefix looks like this:
```nginx
location = /allstarr { return 308 /allstarr/; }
location /allstarr/ {
proxy_pass http://allstarr:5275/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_buffering off;
}
```
This location does not add authentication. Protect the entire path with your access proxy or private network, including API, asset, and OIDC callback routes. Configure the existing admin bind/allowlist policy for your proxy network; enabling a prefix does not open the listener. Trust forwarded headers only from your proxy, using `ForwardedHeaders__KnownProxies` or `ForwardedHeaders__KnownNetworks` in the container environment. Allstarr uses the trusted request scheme for secure session cookies.
A path is not a browser security boundary. Other applications on `media.example.org` share its origin and can make same-origin requests to Allstarr. Use a dedicated hostname when possible; otherwise, only share the hostname with applications you trust. Cookie paths and an access-proxy login do not remove this risk.
## OIDC login
Register a confidential web client with your identity provider. Enable authorization-code flow with PKCE and register this exact redirect URI:
```text
https://media.example.org/allstarr/api/admin/auth/oidc/callback
```
Configure Allstarr:
```dotenv
ADMIN_BASE_PATH=/allstarr
ADMIN_OIDC_ENABLED=true
ADMIN_OIDC_AUTHORITY=https://identity.example.org
ADMIN_OIDC_CLIENT_ID=allstarr
ADMIN_OIDC_CLIENT_SECRET=replace-with-your-client-secret
ADMIN_OIDC_PUBLIC_URL=https://media.example.org/allstarr
ADMIN_OIDC_DISPLAY_NAME=Home SSO
```
Use your provider's issuer/authority URL, which may include a realm or application path. Both authority and public URL require HTTPS. The public URL must end at the configured base path with no query or fragment. For a dedicated hostname with no prefix, leave `ADMIN_BASE_PATH` empty and use `https://allstarr.example.org` as the public URL.
Keep the client secret in private deployment configuration, not Git or dashboard settings. The Compose deployment reads these keys from its mounted `.env`; direct deployments can use the corresponding `Admin__Oidc__...` configuration keys. Persist the existing data-protection keys and encryption key ring across restarts.
### Link a media-server account
1. Choose **Continue with Home SSO** on the login page.
2. Sign in at the identity provider.
3. Enter your Jellyfin or Subsonic credentials and choose **Link account and sign in**.
Allstarr binds the verified issuer and subject to that exact backend account. Email addresses and IdP role claims do not choose a media account or grant administrator access. One SSO identity links to one native account, and a native account has one SSO link. Disconnect an existing link before choosing another account.
For Jellyfin, Allstarr stores the linked account's access token encrypted. For Subsonic, it stores the supplied native credential encrypted; the linking screen explains this before submission. Ordinary Subsonic login does not retain that credential. On future SSO logins, Allstarr checks the stored credential with the selected media server and reads its current permissions. A changed backend URL, rejected credential, or disabled account prevents SSO login.
SSO creates a normal 12-hour Allstarr session. Signing out ends the local session, not the identity-provider session. Native username/password login remains available if the identity provider is down.
### Disconnect and recover
Use **Disconnect SSO** while signed in to remove your link and revoke its stored credential. Existing SSO-created Allstarr sessions stop working; a native-login session remains usable. If a native credential expires, start SSO again and authenticate the same media account to renew the link.
Setting `ADMIN_OIDC_ENABLED=false` and recreating the container disables SSO and its existing sessions without changing music-client login. A full PostgreSQL backup preserves links. Portable and selective state exports exclude SSO links and their credentials; link accounts again after a portable restore.
If callbacks fail, check the exact redirect URI, public URL, proxy path handling, and trusted forwarded-protocol configuration. Do not include authorization codes, cookies, passwords, or tokens in bug reports.
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f8fafd" />
<meta name="allstarr-base-path" content="" />
<script>
(() => {
const saved = localStorage.getItem("allstarr.theme") || "system";
+26
View File
@@ -0,0 +1,26 @@
import { json, type Session } from "./api";
export type OidcStatus = {
enabled: boolean;
displayName?: string;
loginUrl?: string;
linkPending?: boolean;
linked?: boolean;
csrfToken?: string;
};
export const adminOidc = {
status: () => json<OidcStatus>("/api/admin/auth/oidc/status"),
link: (username: string, password: string, rememberMe: boolean, csrfToken: string) =>
json<Session>("/api/admin/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Allstarr-CSRF": csrfToken },
body: JSON.stringify({ username, password, rememberMe, linkOidc: true }),
}),
unlink: (csrfToken: string) => json<{ success: boolean }>("/api/admin/auth/oidc/link", {
method: "DELETE", headers: { "X-Allstarr-CSRF": csrfToken },
}),
cancel: (csrfToken: string) => json<{ success: boolean }>("/api/admin/auth/oidc/pending", {
method: "DELETE", headers: { "X-Allstarr-CSRF": csrfToken },
}),
};
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminUrl } from "./admin-url";
afterEach(() => vi.unstubAllGlobals());
function setBasePath(content: string | null) {
vi.stubGlobal("document", {
querySelector: vi.fn().mockReturnValue(content === null ? null : {
getAttribute: vi.fn().mockReturnValue(content),
}),
});
}
describe("adminUrl", () => {
it("defaults to root-relative URLs during SSR or development", () => {
expect(adminUrl("/api/admin/ui/home")).toBe("/api/admin/ui/home");
});
it("prefixes same-origin root paths and is idempotent", () => {
setBasePath("/control-room/");
expect(adminUrl("/api/admin/ui/home")).toBe("/control-room/api/admin/ui/home");
expect(adminUrl("/")).toBe("/control-room/");
expect(adminUrl("/control-room/api/admin/ui/home")).toBe("/control-room/api/admin/ui/home");
expect(adminUrl("/control-room?return=1")).toBe("/control-room?return=1");
});
it("leaves non-root and external URL forms untouched", () => {
setBasePath("/admin");
for (const value of [
"#/library",
"api/admin/ui/home",
"./favicon.svg",
"https://example.test/image.svg",
"//cdn.example.test/image.svg",
"mailto:admin@example.test",
]) {
expect(adminUrl(value)).toBe(value);
}
});
it("ignores malformed metadata instead of constructing an unsafe URL", () => {
for (const value of ["admin", "/admin//", "/admin/%2e%2e", "/admin?x=1"]) {
setBasePath(value);
expect(adminUrl("/api/admin/ui/home")).toBe("/api/admin/ui/home");
}
});
});
+41
View File
@@ -0,0 +1,41 @@
const ADMIN_BASE_PATH = /^\/(?:[A-Za-z0-9_-]+)(?:\/(?:[A-Za-z0-9_-]+))*$/;
function readAdminBasePath(): string {
const dom = (globalThis as typeof globalThis & { document?: Document }).document;
if (!dom) return "";
let configured = "";
try {
configured = dom
.querySelector('meta[name="allstarr-base-path"]')
?.getAttribute("content")
?.trim() ?? "";
} catch {
return "";
}
if (!configured || configured === "/") return "";
const normalized = configured.endsWith("/") ? configured.slice(0, -1) : configured;
return ADMIN_BASE_PATH.test(normalized) ? normalized : "";
}
/**
* Prefixes a same-origin root-relative admin URL with the configured mount
* path. Hash routes, relative URLs, and external URLs are intentionally left
* untouched because they have different URL ownership semantics.
*/
export function adminUrl(path: string): string {
if (!path || !path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\"))
return path;
const basePath = readAdminBasePath();
if (!basePath) return path;
if (path === basePath ||
path.startsWith(`${basePath}/`) ||
path.startsWith(`${basePath}?`) ||
path.startsWith(`${basePath}#`))
return path;
return path === "/" ? `${basePath}/` : `${basePath}${path}`;
}
+6 -3
View File
@@ -1,3 +1,5 @@
import { adminUrl } from "./admin-url";
export type Session = {
authenticated: boolean;
backend: string;
@@ -1201,7 +1203,8 @@ export type ListeningHistoryImport = {
};
async function request(input: RequestInfo | URL, init?: RequestInit) {
const response = await fetch(input, {
const requestInput = typeof input === "string" ? adminUrl(input) : input;
const response = await fetch(requestInput, {
cache: "no-store",
credentials: "same-origin",
...init,
@@ -1377,7 +1380,7 @@ export const intelligence = {
json<void>(`/api/admin/intelligence/history/${encodeURIComponent(id)}`,
intelligenceBody({ ...scope, expectedRevision, confirmed: true }, "DELETE")),
historyExportUrl: (scope: IntelligenceScope) =>
`/api/admin/intelligence/history/export?${intelligenceQuery(scope)}`,
adminUrl(`/api/admin/intelligence/history/export?${intelligenceQuery(scope)}`),
previewHistoryImport: (scope: IntelligenceScope, file: File) => {
const body = new FormData();
body.append("file", file);
@@ -1736,7 +1739,7 @@ export const downloads = {
{ method: "DELETE" },
),
fileUrl: (path: string, storage: "cache" | "kept") =>
`/api/admin/downloads/file?path=${encodeURIComponent(path)}&storage=${storage}`,
adminUrl(`/api/admin/downloads/file?path=${encodeURIComponent(path)}&storage=${storage}`),
};
export const playlistLinks = {
@@ -1,4 +1,5 @@
<script lang="ts">
import { adminUrl } from "$lib/admin-url";
import { differenceHash, hashSimilarity, percent } from "$lib/mappings";
let { source, candidate }: { source: string; candidate: string } = $props();
@@ -16,7 +17,7 @@
});
async function fingerprint(url: string) {
const response = await fetch(url);
const response = await fetch(adminUrl(url));
if (!response.ok) throw new Error("Artwork unavailable");
const bitmap = await createImageBitmap(await response.blob());
try {
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { ChevronDown } from "@lucide/svelte";
import { adminUrl } from "$lib/admin-url";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Badge } from "$lib/components/ui/badge";
@@ -246,7 +247,7 @@
<span class="media-art download-art">
<ProviderMark id={file.provider || "file"} definition={provider(file.provider)} label={providerName(file.provider)} />
{#if file.artworkUrl}
<img src={file.artworkUrl} alt="" loading="lazy" onerror={(event) => event.currentTarget.remove()} />
<img src={adminUrl(file.artworkUrl)} alt="" loading="lazy" onerror={(event) => event.currentTarget.remove()} />
{/if}
</span>
<span>
+2 -1
View File
@@ -2,6 +2,7 @@
import { onMount, tick } from "svelte";
import { flip } from "svelte/animate";
import { ArrowRight, ChevronRight } from "@lucide/svelte";
import { adminUrl } from "$lib/admin-url";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
@@ -273,7 +274,7 @@
<span class="event-kind-icon" data-severity={severityState} data-kind={first.kind} aria-hidden="true">
<span><ActivityIcon kind={first.kind} /></span>
{#if first.artworkUrl}
<img src={first.artworkUrl} alt="" loading="lazy" onerror={(event) => event.currentTarget.remove()} />
<img src={adminUrl(first.artworkUrl)} alt="" loading="lazy" onerror={(event) => event.currentTarget.remove()} />
{/if}
</span>
<span class="event-summary-copy">
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { Activity, CircleCheck, CircleDashed, HardDrive, Headphones, KeyRound, ListMusic, Mic2, Route, Server, TrendingUp } from "@lucide/svelte";
import { adminUrl } from "$lib/admin-url";
import { home } from "$lib/api";
import { humanize, relativeTime } from "$lib/activity";
import ProviderMark from "$lib/components/ProviderMark.svelte";
@@ -190,7 +191,7 @@
<span class="listener-avatar" aria-label={`${item.userName} profile`}>
<span aria-hidden="true">{initials(item.userName)}</span>
{#if item.avatarUrl}
<img src={item.avatarUrl} alt="" onerror={(event) => event.currentTarget.remove()} />
<img src={adminUrl(item.avatarUrl)} alt="" onerror={(event) => event.currentTarget.remove()} />
{/if}
</span>
<span>
@@ -202,7 +203,7 @@
<div class="now-playing-track">
<span class="now-playing-artwork">
{#if item.artworkUrl}
<img src={item.artworkUrl} alt="" />
<img src={adminUrl(item.artworkUrl)} alt="" />
{:else}
<span aria-hidden="true">♫</span>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { FileUp, Heart, History, LayoutDashboard, ListMusic, SlidersHorizontal, Sparkles, X } from "@lucide/svelte";
import { adminUrl } from "$lib/admin-url";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import DisclosureLabel from "$lib/components/DisclosureLabel.svelte";
import { Checkbox } from "$lib/components/ui/checkbox";
@@ -558,7 +559,7 @@
<ol class="recommendation-list">
{#each visibleCandidates as item}
<li>
<span class="track-art">{#if item.artworkUrl}<img src={item.artworkUrl} alt="" loading="lazy" />{:else}<span aria-hidden="true">♪</span>{/if}</span>
<span class="track-art">{#if item.artworkUrl}<img src={adminUrl(item.artworkUrl)} alt="" loading="lazy" />{:else}<span aria-hidden="true">♪</span>{/if}</span>
<div class="track-copy"><strong>{item.title || item.trackKey}</strong><small>{item.artist || item.providerId}{item.album ? ` · ${item.album}` : ""}</small><details><summary class="disclosure-summary compact"><DisclosureLabel title="Why this track" /></summary><ul>{#each item.explanations as reason}<li>{reason.explanation}</li>{/each}</ul></details></div>
<div class="track-actions"><span class="score">{Math.round(item.score * 100)}%</span><Button variant="outline" size="xs" disabled={Boolean(action)} onclick={() => void perform(`similar:${item.id}`, () => intelligence.run(activeScope, [item.trackKey]))}>Similar</Button><Button variant={item.feedback?.kind === "like" ? "secondary" : "ghost"} size="icon-xs" aria-label={`${item.feedback?.kind === "like" ? "Liked" : "Like"} ${item.title || item.trackKey}`} aria-pressed={item.feedback?.kind === "like"} disabled={Boolean(action) || item.feedback?.kind === "like"} onclick={() => void perform(`like:${item.id}`, () => intelligence.feedback(activeScope, item.id, "like", item.feedback?.revision ?? 0))}><Heart size={16} fill={item.feedback?.kind === "like" ? "currentColor" : "none"} aria-hidden="true" /></Button><Button variant="ghost" size="icon-xs" aria-label={`Dismiss ${item.title || item.trackKey}`} disabled={Boolean(action)} onclick={() => void perform(`dismiss:${item.id}`, () => intelligence.feedback(activeScope, item.id, "dismiss", item.feedback?.revision ?? 0))}><X size={16} aria-hidden="true" /></Button></div>
</li>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { adminUrl } from "$lib/admin-url";
import { Dialog } from "$lib/components/ui/dialog";
import { Button, buttonVariants } from "$lib/components/ui/button";
import { X } from "@lucide/svelte";
@@ -218,7 +219,7 @@
<section class="mapping-source">
<span class="media-art mapping-art">
{#if match.sourceArtworkUrl}<img src={match.sourceArtworkUrl} alt="" />{:else}<ProviderMark id={match.providerId} definition={provider(match.providerId)} />{/if}
{#if match.sourceArtworkUrl}<img src={adminUrl(match.sourceArtworkUrl)} alt="" />{:else}<ProviderMark id={match.providerId} definition={provider(match.providerId)} />{/if}
</span>
<div>
<small>Source track</small>
+5 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { adminUrl } from "$lib/admin-url";
let {
url,
fallback = "♪",
@@ -12,16 +14,18 @@
} = $props();
let failed = $state(false);
const source = $derived(url ? adminUrl(url) : "");
$effect(() => {
url;
source;
failed = false;
});
</script>
<span class={`media-art ${className}`}>
{#if url && !failed}
<img src={url} alt="" {loading} onerror={() => { failed = true; }} />
<img src={source} alt="" {loading} onerror={() => { failed = true; }} />
{:else}
<span aria-hidden="true">{fallback}</span>
{/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { adminUrl } from "$lib/admin-url";
import type { ProviderDefinition } from "$lib/api";
import { providerColor } from "$lib/playlists";
@@ -13,7 +14,7 @@
? definition.icon
: label.toLowerCase().replace(/[^a-z0-9]/g, ""),
);
const source = $derived(definition?.logoUrl || `/images/providers/${encodeURIComponent(icon || id.toLowerCase())}.svg`);
const source = $derived(adminUrl(definition?.logoUrl || `/images/providers/${encodeURIComponent(icon || id.toLowerCase())}.svg`));
$effect(() => {
source;
+3 -1
View File
@@ -1,3 +1,5 @@
import { adminUrl } from "./admin-url";
export type LiveState = "connecting" | "live" | "reconnecting" | "stale";
type UpdateEvent = {
@@ -81,7 +83,7 @@ export const liveUpdates = {
if (source) return;
state.status = "connecting";
source = new EventSource("/api/admin/updates/stream");
source = new EventSource(adminUrl("/api/admin/updates/stream"));
source.addEventListener("stream-status", () => {
if (staleTimer) clearTimeout(staleTimer);
state.status = "live";
+63 -6
View File
@@ -2,6 +2,8 @@
import { onMount, type Component } from "svelte";
import { page } from "$app/state";
import { auth, onboarding, type OnboardingState, type Session } from "$lib/api";
import { adminUrl } from "$lib/admin-url";
import { adminOidc, type OidcStatus } from "$lib/admin-oidc";
import { liveUpdates } from "$lib/live-updates.svelte";
import RouteError from "$lib/components/RouteError.svelte";
import SegmentedNav from "$lib/components/SegmentedNav.svelte";
@@ -35,6 +37,7 @@
];
let session = $state<Session | null>(null);
let oidc = $state<OidcStatus>({ enabled: false });
let loading = $state(true);
let bootstrapFailed = $state(false);
let bootMessageIndex = $state(0);
@@ -231,6 +234,10 @@
bootMessageIndex = 0;
try {
session = await auth.session();
oidc = await adminOidc.status().catch(() => ({ enabled: false }));
if (new URLSearchParams(window.location.search).get("oidc") === "failed") {
error = "SSO sign-in failed. Try again, or use your media-server account.";
}
if (session.authenticated) {
await loadOnboarding(session);
liveUpdates.connect();
@@ -274,7 +281,10 @@
authBusy = true;
error = "";
try {
session = await auth.login(username, password, rememberMe);
session = oidc.linkPending
? await adminOidc.link(username, password, rememberMe, oidc.csrfToken ?? "")
: await auth.login(username, password, rememberMe);
oidc = await adminOidc.status().catch(() => ({ enabled: false }));
avatarFailed = false;
password = "";
await loadOnboarding(session);
@@ -296,12 +306,40 @@
onboardingState = null;
onboardingOpen = false;
session = await auth.session();
oidc = await adminOidc.status().catch(() => ({ enabled: false }));
} catch (cause) {
authError = cause instanceof Error ? cause.message : "You are still signed in. Try again.";
} finally {
authBusy = false;
}
}
async function disconnectSso() {
if (authBusy || !confirm("Disconnect SSO? You can still sign in with your media-server account.")) return;
authBusy = true;
authError = "";
try {
await adminOidc.unlink(oidc.csrfToken ?? "");
await bootstrap();
} catch (cause) {
authError = cause instanceof Error ? cause.message : "Could not disconnect SSO.";
} finally {
authBusy = false;
}
}
async function cancelSsoLink() {
if (authBusy) return;
authBusy = true;
try {
await adminOidc.cancel(oidc.csrfToken ?? "");
oidc = await adminOidc.status();
} catch (cause) {
error = cause instanceof Error ? cause.message : "Could not cancel SSO linking.";
} finally {
authBusy = false;
}
}
</script>
<svelte:head>
@@ -341,15 +379,25 @@
<button class="auth-submit mt-6 w-full" type="button" onclick={() => void bootstrap()}>Try again</button>
</section>
</main>
{:else if !session?.authenticated}
{:else if !session?.authenticated || oidc.linkPending}
<main class="grid min-h-screen place-items-center p-6">
<section class="panel w-full max-w-sm p-6 sm:p-8" aria-labelledby="login-title">
<div class="brand-mark mb-6" aria-hidden="true">A</div>
<p class="eyebrow">Allstarr</p>
<h1 id="login-title" class="mt-2 text-3xl font-semibold tracking-tight">Your music, connected.</h1>
<p class="mt-3 text-sm leading-6 text-ink-muted">
Sign in with your {session?.backend ?? "media server"} account.
{#if oidc.linkPending}
Link your {session?.backend ?? "media server"} account to SSO. Your media server still controls your access.
{#if session?.backend?.toLowerCase() === "subsonic"}
Allstarr will keep your media-server credential encrypted to verify future SSO logins.
{/if}
{:else}
Sign in with your {session?.backend ?? "media server"} account.
{/if}
</p>
{#if oidc.enabled && !oidc.linkPending && oidc.loginUrl}
<a class="button mt-4 w-full" href={adminUrl(oidc.loginUrl)}>Continue with {oidc.displayName}</a>
{/if}
<form class="mt-8 space-y-4" aria-busy={authBusy} onsubmit={(event) => { event.preventDefault(); void login(); }}>
<label class="field">
@@ -365,7 +413,10 @@
Keep me signed in
</label>
{#if error}<p class="notice-error" role="alert">{error}</p>{/if}
<button class="auth-submit w-full" type="submit" disabled={authBusy}>{authBusy ? "Signing in…" : "Sign in"}</button>
<button class="auth-submit w-full" type="submit" disabled={authBusy}>{authBusy ? "Signing in…" : oidc.linkPending ? "Link account and sign in" : "Sign in"}</button>
{#if oidc.linkPending}
<button class="button w-full" type="button" disabled={authBusy} onclick={() => void cancelSsoLink()}>Continue without linking</button>
{/if}
</form>
</section>
</main>
@@ -435,7 +486,7 @@
<span class="avatar" aria-hidden="true">
{#if session.user?.avatarUrl && !avatarFailed}
<img
src={session.user.avatarUrl}
src={adminUrl(session.user.avatarUrl)}
alt=""
onerror={() => {
avatarFailed = true;
@@ -460,6 +511,12 @@
</aside>
<main class="workspace" id="main-workspace">
{#if oidc.linked}
<div class="flex items-center justify-end gap-3 p-2 text-sm">
<span class="text-ink-muted">{oidc.displayName} connected</span>
<button class="button" disabled={authBusy} onclick={() => void disconnectSso()}>Disconnect SSO</button>
</div>
{/if}
<header class="workspace-header">
<div class="workspace-title">
<h1>{activeDestination.label}</h1>
@@ -554,7 +611,7 @@
<section class="mobile-sheet-profile" aria-label="Signed-in account">
<span class="avatar" aria-hidden="true">
{#if session.user?.avatarUrl && !avatarFailed}
<img src={session.user.avatarUrl} alt="" onerror={() => avatarFailed = true} />
<img src={adminUrl(session.user.avatarUrl)} alt="" onerror={() => avatarFailed = true} />
{:else}
<span>{initials}</span>
{/if}
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
for (const width of [390, 1280]) {
test(`SSO linking requires native credentials and allows cancellation at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
let pending = true;
let linked = false;
let authenticated = false;
let submitted: unknown;
await page.route("**/api/admin/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === "/api/admin/auth/me") return route.fulfill({ json: {
authenticated, backend: "Subsonic", user: authenticated ? { name: "Listener", isAdministrator: false } : undefined,
} });
if (path === "/api/admin/auth/oidc/status") return route.fulfill({ json: {
enabled: true, displayName: "Home SSO", loginUrl: "/api/admin/auth/oidc/login",
linkPending: pending, linked, csrfToken: "fixture-csrf",
} });
if (path === "/api/admin/auth/oidc/pending") {
expect(route.request().method()).toBe("DELETE");
expect(route.request().headers()["x-allstarr-csrf"]).toBe("fixture-csrf");
pending = false;
return route.fulfill({ json: { success: true } });
}
if (path === "/api/admin/auth/login") {
submitted = route.request().postDataJSON();
expect(route.request().headers()["x-allstarr-csrf"]).toBe("fixture-csrf");
pending = false;
linked = authenticated = true;
return route.fulfill({ json: { authenticated, backend: "Subsonic", user: { name: "Listener", isAdministrator: false } } });
}
if (path === "/api/admin/auth/oidc/link") {
expect(route.request().method()).toBe("DELETE");
expect(route.request().headers()["x-allstarr-csrf"]).toBe("fixture-csrf");
linked = false;
return route.fulfill({ json: { success: true } });
}
if (path === "/api/admin/updates/stream") return route.fulfill({ contentType: "text/event-stream", body: ": fixture\n\n" });
return route.fulfill({ json: {} });
});
await page.goto("/");
await expect(page.getByRole("button", { name: "Link account and sign in" })).toBeVisible();
await expect(page.getByText("Allstarr will keep your media-server credential encrypted", { exact: false })).toBeVisible();
await page.getByRole("button", { name: "Continue without linking" }).click();
await expect(page.getByRole("link", { name: "Continue with Home SSO" })).toHaveAttribute("href", "/api/admin/auth/oidc/login");
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
pending = true;
await page.reload();
await page.getByLabel("Username", { exact: true }).fill("listener");
await page.getByLabel("Password", { exact: true }).fill("fixture-password");
await page.getByRole("button", { name: "Link account and sign in" }).click();
await expect(page.getByRole("button", { name: "Disconnect SSO" })).toBeVisible();
expect(submitted).toMatchObject({ username: "listener", password: "fixture-password", linkOidc: true });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Disconnect SSO" }).click();
await expect(page.getByRole("button", { name: "Disconnect SSO" })).toHaveCount(0);
});
}