diff --git a/API.IntegrationTests/Tests/AutomationTokenTests.cs b/API.IntegrationTests/Tests/AutomationTokenTests.cs
new file mode 100644
index 00000000..ca3a653b
--- /dev/null
+++ b/API.IntegrationTests/Tests/AutomationTokenTests.cs
@@ -0,0 +1,407 @@
+using System.Net;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.API.IntegrationTests.Helpers;
+using OpenShock.Common.Constants;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.AutomationTokens;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.IntegrationTests.Tests;
+
+///
+/// Automation tokens lift Turnstile (and rate limits, which the test server disables anyway) for any
+/// account except privileged ones, so these tests use an invalid Turnstile response to observe whether
+/// a token was honored.
+///
+public sealed class AutomationTokenTests
+{
+ private const string Password = "SecurePassword123#";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory WebApplicationFactory { get; init; }
+
+ // --- Signup ---
+
+ [Test]
+ public async Task Signup_WithTurnstileToken_BypassesTurnstileAndLinksAccount()
+ {
+ var (tokenId, secret) = await CreateAutomationTokenInDb();
+ using var client = CreateAutomationClient(secret);
+ var email = TestHelper.UniqueEmail("autosignup");
+
+ var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
+ {
+ username = TestHelper.UniqueUsername("autosignup"),
+ password = Password,
+ email,
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var user = await db.Users.AsNoTracking().FirstAsync(u => u.Email == email);
+ await Assert.That(user.CreatedByAutomationTokenId).IsEqualTo(tokenId);
+ await Assert.That(await HasUsedAuditAsync(db, user.Id)).IsTrue();
+ }
+
+ [Test]
+ public async Task Signup_WithUnknownToken_StillRequiresTurnstile()
+ {
+ using var client = CreateAutomationClient(IAutomationTokenService.GenerateSecret());
+
+ var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
+ {
+ username = TestHelper.UniqueUsername("autounknown"),
+ password = Password,
+ email = TestHelper.UniqueEmail("autounknown"),
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Signup_WithRateLimitOnlyToken_StillRequiresTurnstile()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb([AutomationTokenType.RateLimit]);
+ using var client = CreateAutomationClient(secret);
+
+ var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
+ {
+ username = TestHelper.UniqueUsername("autoratelimit"),
+ password = Password,
+ email = TestHelper.UniqueEmail("autoratelimit"),
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ // --- Privileged callers (middleware) ---
+
+ [Test]
+ public async Task PrivilegedSession_TokenIsRefused()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var staff = await CreateUserWithRoles("autostaffsession", [RoleType.Staff]);
+
+ using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, staff.SessionToken);
+ client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
+
+ var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
+ {
+ username = TestHelper.UniqueUsername("autostaffsessionsignup"),
+ password = Password,
+ email = TestHelper.UniqueEmail("autostaffsessionsignup"),
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task PrivilegedApiToken_TokenIsRefused()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var staff = await CreateUserWithRoles("autostaffapi", [RoleType.Staff]);
+ var (_, apiToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, staff.Id);
+
+ using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, apiToken);
+ client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
+
+ var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
+ {
+ username = TestHelper.UniqueUsername("autostaffapisignup"),
+ password = Password,
+ email = TestHelper.UniqueEmail("autostaffapisignup"),
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task NormalApiToken_TokenIsHonored()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autonormalapi"), TestHelper.UniqueEmail("autonormalapi"), Password);
+ var (_, apiToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId);
+
+ using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, apiToken);
+ client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
+
+ var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
+ {
+ username = TestHelper.UniqueUsername("autonormalapisignup"),
+ password = Password,
+ email = TestHelper.UniqueEmail("autonormalapisignup"),
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ }
+
+ // --- Login ---
+
+ [Test]
+ public async Task Login_ForNormalAccount_BypassesTurnstileAndAudits()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var email = TestHelper.UniqueEmail("autologin");
+ var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autologin"), email, Password);
+ using var client = CreateAutomationClient(secret);
+
+ var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
+ {
+ usernameOrEmail = email,
+ password = Password,
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await Assert.That(await HasUsedAuditAsync(db, userId)).IsTrue();
+ }
+
+ [Test]
+ public async Task Login_ForPrivilegedAccount_Returns403()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var admin = await CreateUserWithRoles("autologinadmin", [RoleType.Admin]);
+ using var client = CreateAutomationClient(secret);
+
+ var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
+ {
+ usernameOrEmail = admin.Email,
+ password = Password,
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ var body = await response.Content.ReadAsStringAsync();
+ await Assert.That(body).Contains("AutomationToken.NotAllowedForAccount");
+ }
+
+ // --- Password reset ---
+
+ [Test]
+ public async Task PasswordReset_ForPrivilegedAccount_ReturnsOkWithoutStartingReset()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var staff = await CreateUserWithRoles("autoresetstaff", [RoleType.Staff]);
+ using var client = CreateAutomationClient(secret);
+
+ var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new
+ {
+ email = staff.Email,
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await Assert.That(await db.UserPasswordResets.AnyAsync(r => r.UserId == staff.Id)).IsFalse();
+ }
+
+ [Test]
+ public async Task PasswordReset_ForNormalAccount_StartsResetAndAudits()
+ {
+ var (_, secret) = await CreateAutomationTokenInDb();
+ var email = TestHelper.UniqueEmail("autoreset");
+ var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autoreset"), email, Password);
+ using var client = CreateAutomationClient(secret);
+
+ var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new
+ {
+ email,
+ turnstileResponse = "invalid-token"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await Assert.That(await db.UserPasswordResets.AnyAsync(r => r.UserId == userId)).IsTrue();
+ await Assert.That(await HasUsedAuditAsync(db, userId)).IsTrue();
+ }
+
+ // --- Admin endpoints ---
+
+ [Test]
+ public async Task Create_ReturnsWorkingSecret()
+ {
+ using var client = await CreateAdminClient("autocreate");
+
+ var response = await client.PostAsync("/1/admin/automationTokens", TestHelper.JsonContent(new
+ {
+ name = "created-by-test",
+ types = new[] { "Turnstile" }
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var id = json.RootElement.GetProperty("id").GetGuid();
+ var secret = json.RootElement.GetProperty("secret").GetString()!;
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ var resolved = await service.ResolveAsync(secret, CancellationToken.None);
+ await Assert.That(resolved).IsNotNull();
+ await Assert.That(resolved!.Id).IsEqualTo(id);
+ await Assert.That(resolved.Types).Contains(AutomationTokenType.Turnstile);
+ }
+
+ [Test]
+ public async Task Rotate_OldSecretStopsResolving()
+ {
+ var (tokenId, oldSecret) = await CreateAutomationTokenInDb();
+ using var client = await CreateAdminClient("autorotate");
+
+ var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var newSecret = json.RootElement.GetProperty("secret").GetString()!;
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ await Assert.That(await service.ResolveAsync(oldSecret, CancellationToken.None)).IsNull();
+ await Assert.That((await service.ResolveAsync(newSecret, CancellationToken.None))?.Id).IsEqualTo(tokenId);
+ }
+
+ [Test]
+ public async Task Patch_CleanupRequiresDelay_AndSwitchingOffClearsIt()
+ {
+ var (tokenId, _) = await CreateAutomationTokenInDb();
+ using var client = await CreateAdminClient("autopatch");
+
+ var missingDelay = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new
+ {
+ autoCleanupUsers = true
+ }));
+ await Assert.That(missingDelay.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+
+ var enable = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new
+ {
+ autoCleanupUsers = true,
+ autoCleanupAfter = "01:00:00"
+ }));
+ await Assert.That(enable.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var disable = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new
+ {
+ autoCleanupUsers = false
+ }));
+ await Assert.That(disable.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var token = await db.AutomationTokens.AsNoTracking().FirstAsync(t => t.Id == tokenId);
+ await Assert.That(token.AutoCleanupUsers).IsFalse();
+ await Assert.That(token.AutoCleanupAfter).IsNull();
+ }
+
+ [Test]
+ public async Task Delete_WithPrivilegedCreatedAccount_Returns409AndKeepsEverything()
+ {
+ var (tokenId, _) = await CreateAutomationTokenInDb();
+ var normalId = await CreateUserCreatedByToken(tokenId, "autodelnormal", []);
+ var staffId = await CreateUserCreatedByToken(tokenId, "autodelstaff", [RoleType.Staff]);
+ using var client = await CreateAdminClient("autodelete409");
+
+ var response = await client.DeleteAsync($"/1/admin/automationTokens/{tokenId}");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await Assert.That(await db.AutomationTokens.AnyAsync(t => t.Id == tokenId)).IsTrue();
+ await Assert.That(await db.Users.AnyAsync(u => u.Id == normalId)).IsTrue();
+ await Assert.That(await db.Users.AnyAsync(u => u.Id == staffId)).IsTrue();
+ }
+
+ [Test]
+ public async Task Delete_DeletesTokenAndTheAccountsItCreated()
+ {
+ var (tokenId, _) = await CreateAutomationTokenInDb();
+ var createdId = await CreateUserCreatedByToken(tokenId, "autodelcreated", []);
+ var unrelatedId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autodelunrelated"), TestHelper.UniqueEmail("autodelunrelated"), Password);
+ using var client = await CreateAdminClient("autodelete");
+
+ var response = await client.DeleteAsync($"/1/admin/automationTokens/{tokenId}");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await Assert.That(await db.AutomationTokens.AnyAsync(t => t.Id == tokenId)).IsFalse();
+ await Assert.That(await db.Users.AnyAsync(u => u.Id == createdId)).IsFalse();
+ await Assert.That(await db.Users.AnyAsync(u => u.Id == unrelatedId)).IsTrue();
+ }
+
+ // --- Helpers ---
+
+ private HttpClient CreateAutomationClient(string secret)
+ {
+ var client = WebApplicationFactory.CreateClient();
+ client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
+ return client;
+ }
+
+ private async Task<(Guid Id, string Secret)> CreateAutomationTokenInDb(List? types = null)
+ {
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var secret = IAutomationTokenService.GenerateSecret();
+ var id = Guid.CreateVersion7();
+ db.AutomationTokens.Add(new AutomationToken
+ {
+ Id = id,
+ Name = "test-token",
+ TokenHash = HashingUtils.HashToken(secret),
+ Types = types ?? [AutomationTokenType.Turnstile, AutomationTokenType.RateLimit]
+ });
+ await db.SaveChangesAsync();
+ return (id, secret);
+ }
+
+ private async Task CreateUserWithRoles(string prefix, List roles)
+ {
+ var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Users.Where(u => u.Id == user.Id).ExecuteUpdateAsync(s => s.SetProperty(u => u.Roles, roles));
+
+ return user;
+ }
+
+ private async Task CreateUserCreatedByToken(Guid tokenId, string prefix, List roles)
+ {
+ var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password);
+
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Users.Where(u => u.Id == userId).ExecuteUpdateAsync(s => s
+ .SetProperty(u => u.CreatedByAutomationTokenId, tokenId)
+ .SetProperty(u => u.Roles, roles));
+
+ return userId;
+ }
+
+ private async Task CreateAdminClient(string prefix)
+ {
+ var admin = await CreateUserWithRoles(prefix, [RoleType.Admin]);
+ return TestHelper.CreateAuthenticatedClient(WebApplicationFactory, admin.SessionToken);
+ }
+
+ private static Task HasUsedAuditAsync(OpenShockContext db, Guid userId) =>
+ db.UserAuditLogs.AnyAsync(l => l.UserId == userId && l.Action == AuditAction.AutomationTokenUsed);
+}
diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs
index b9139e4d..bbf67df7 100644
--- a/API/Controller/Account/LoginV2.cs
+++ b/API/Controller/Account/LoginV2.cs
@@ -8,6 +8,8 @@
using OpenShock.Common.Errors;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Problems;
+using OpenShock.Common.Services.AutomationTokens;
+using OpenShock.API.Errors;
using OpenShock.API.Models.Response;
using OpenShock.API.Services.Turnstile;
using Results = OpenShock.Common.Results;
@@ -28,11 +30,12 @@ public sealed partial class AccountController
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
- [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
+ [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain, AutomationTokenNotAllowedForAccount
[MapToApiVersion("2")]
public async Task LoginV2(
[FromBody] LoginV2 body,
[FromServices] ICloudflareTurnstileService turnstileService,
+ [FromServices] IAutomationTokenService automationTokens,
CancellationToken cancellationToken)
{
var cookieDomain = GetCurrentCookieDomain();
@@ -41,6 +44,11 @@ public async Task LoginV2(
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
if (turnstileError is not null) return turnstileError;
+ // Checked before the password is: with Turnstile and rate limits lifted, an automation token must not
+ // be usable to guess passwords of privileged accounts.
+ if (!await automationTokens.CanUseForLoginAsync(body.UsernameOrEmail, cancellationToken))
+ return Problem(AutomationTokenError.NotAllowedForAccount);
+
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken);
if (getAccountResult is not User account)
{
@@ -53,6 +61,9 @@ public async Task LoginV2(
_ => throw new UnreachableException()
};
}
+
+ if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken))
+ return Problem(AutomationTokenError.NotAllowedForAccount);
await CreateSession(account.Id, cookieDomain);
diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs
index 1bc1cc31..7e40bf5d 100644
--- a/API/Controller/Account/PasswordResetInitiateV2.cs
+++ b/API/Controller/Account/PasswordResetInitiateV2.cs
@@ -5,7 +5,9 @@
using OpenShock.API.Models.Requests;
using OpenShock.API.Services.Turnstile;
using OpenShock.Common.Errors;
+using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Problems;
+using OpenShock.Common.Services.AutomationTokens;
using OpenShock.Internal.Common.Problems;
@@ -32,11 +34,20 @@ public sealed partial class AccountController
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)]
[MapToApiVersion("2")]
- public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
+ public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken)
{
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
if (turnstileError is not null) return turnstileError;
+ // Privileged accounts must never be reached through a bypassed flow. The lookup runs only on the
+ // bypass path, so the normal path keeps its timing profile, and the response stays the generic
+ // 200 so this does not become an admin-account oracle.
+ if (!await automationTokens.TryRecordUseByEmailAsync(body.Email, AutomationTokenFlow.PasswordReset, cancellationToken))
+ {
+ _logger.LogWarning("Refused a bypassed password reset for a privileged account");
+ return Ok();
+ }
+
await _accountService.CreatePasswordResetFlowAsync(body.Email);
return Ok();
diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs
index 2b02a349..f666a0a5 100644
--- a/API/Controller/Account/SignupV2.cs
+++ b/API/Controller/Account/SignupV2.cs
@@ -46,12 +46,18 @@ public async Task SignUpV2(
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
if (turnstileError is not null) return turnstileError;
- var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
- return creationAction switch
+ // Created with an automation token, the account is linked to it and the use audited in the same transaction.
+ var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(
+ body.Email, body.Username, body.Password, cancellationToken);
+ if (creationAction is not User _)
{
- User _ => Ok(),
- AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists),
- _ => throw new UnreachableException()
- };
+ return creationAction switch
+ {
+ AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists),
+ _ => throw new UnreachableException()
+ };
+ }
+
+ return Ok();
}
}
\ No newline at end of file
diff --git a/API/Controller/Admin/AutomationTokenCreate.cs b/API/Controller/Admin/AutomationTokenCreate.cs
new file mode 100644
index 00000000..f5b34f89
--- /dev/null
+++ b/API/Controller/Admin/AutomationTokenCreate.cs
@@ -0,0 +1,63 @@
+using System.Net.Mime;
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.API.Controller.Admin.DTOs;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.Audit;
+using OpenShock.Common.Services.AutomationTokens;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Creates an automation token. The secret is only returned once, in this response
+ ///
+ [HttpPost("automationTokens")]
+ [Consumes(MediaTypeNames.Application.Json)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task CreateAutomationToken(
+ [FromBody] CreateAutomationTokenDto body,
+ [FromServices] IAuditService auditService,
+ CancellationToken ct)
+ {
+ PedanticallyEnsureAdmin();
+
+ var secret = IAutomationTokenService.GenerateSecret();
+ var token = new AutomationToken
+ {
+ Id = Guid.CreateVersion7(),
+ Name = body.Name.Trim(),
+ TokenHash = HashingUtils.HashToken(secret),
+ Types = [.. body.Types.Distinct()],
+ AutoCleanupUsers = body.AutoCleanupUsers,
+ AutoCleanupAfter = body.AutoCleanupAfter,
+ };
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+
+ _db.AutomationTokens.Add(token);
+ await _db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync(
+ CurrentUser.Id,
+ action: AuditAction.AutomationTokenCreated,
+ actorId: CurrentUser.Id,
+ metadata: new AutomationTokenCreatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter),
+ cancellationToken: ct);
+
+ await transaction.CommitAsync(ct);
+
+ return new CreatedAutomationTokenDto
+ {
+ Id = token.Id,
+ Name = token.Name,
+ Secret = secret,
+ Types = token.Types,
+ CreatedAt = token.CreatedAt,
+ AutoCleanupUsers = token.AutoCleanupUsers,
+ AutoCleanupAfter = token.AutoCleanupAfter,
+ };
+ }
+}
diff --git a/API/Controller/Admin/AutomationTokenDelete.cs b/API/Controller/Admin/AutomationTokenDelete.cs
new file mode 100644
index 00000000..e552a9bc
--- /dev/null
+++ b/API/Controller/Admin/AutomationTokenDelete.cs
@@ -0,0 +1,79 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.Audit;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Deletes an automation token together with every account it created. Refused while any of those
+ /// accounts holds a privileged role
+ ///
+ [HttpDelete("automationTokens/{id}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task DeleteAutomationToken(
+ [FromRoute] Guid id,
+ [FromServices] IAuditService auditService,
+ CancellationToken ct)
+ {
+ PedanticallyEnsureAdmin();
+
+ var token = await _db.AutomationTokens.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, ct);
+ if (token is null) return NotFound();
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+
+ var createdUsers = await _db.Users
+ .Where(u => u.CreatedByAutomationTokenId == id)
+ .Select(u => new { u.Id, u.Roles })
+ .ToListAsync(ct);
+
+ if (createdUsers.Any(u => PrivilegedRoles.Any(u.Roles))) return PrivilegedAccountConflict();
+
+ var userIds = createdUsers.Select(u => u.Id).ToArray();
+
+ // The roles are checked again as the rows are deleted, so an account promoted since it was read is kept.
+ await _db.Database.ExecuteSqlAsync(
+ $"DELETE FROM users WHERE id = ANY({userIds}) AND NOT (roles && {PrivilegedRoles.All})", ct);
+
+ // The foreign key restricts, so this fails while any account still references the token: a privileged
+ // one, or one created or promoted meanwhile. On success, exactly userIds went with it.
+ try
+ {
+ await _db.AutomationTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct);
+ }
+ catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.ForeignKeyViolation)
+ {
+ return PrivilegedAccountConflict();
+ }
+
+ await auditService.LogAsync(
+ CurrentUser.Id,
+ action: AuditAction.AutomationTokenDeleted,
+ actorId: CurrentUser.Id,
+ metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, userIds.Length),
+ cancellationToken: ct);
+
+ await transaction.CommitAsync(ct);
+
+ // Each deleted account's own audit log goes with it, so the log line is the lasting record of what went.
+ foreach (var userId in userIds)
+ {
+ _logger.LogInformation(
+ "Deleted account {UserId} together with automation token {AutomationTokenId}",
+ userId, token.Id);
+ }
+
+ return Ok();
+
+ IActionResult PrivilegedAccountConflict() => Problem(
+ "This automation token created a privileged account; remove its privileged roles before deleting the token.",
+ statusCode: StatusCodes.Status409Conflict);
+ }
+}
diff --git a/API/Controller/Admin/AutomationTokenList.cs b/API/Controller/Admin/AutomationTokenList.cs
new file mode 100644
index 00000000..57672668
--- /dev/null
+++ b/API/Controller/Admin/AutomationTokenList.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.API.Controller.Admin.DTOs;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Lists all automation tokens
+ ///
+ [HttpGet("automationTokens")]
+ public async IAsyncEnumerable ListAutomationTokens()
+ {
+ PedanticallyEnsureAdmin();
+
+ await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable())
+ {
+ yield return AutomationTokenDto.FromEntity(token);
+ }
+ }
+}
diff --git a/API/Controller/Admin/AutomationTokenPatch.cs b/API/Controller/Admin/AutomationTokenPatch.cs
new file mode 100644
index 00000000..09012466
--- /dev/null
+++ b/API/Controller/Admin/AutomationTokenPatch.cs
@@ -0,0 +1,60 @@
+using System.Net.Mime;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.API.Controller.Admin.DTOs;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.Audit;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Updates an automation token
+ ///
+ [HttpPatch("automationTokens/{id}")]
+ [Consumes(MediaTypeNames.Application.Json)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task PatchAutomationToken(
+ [FromRoute] Guid id,
+ [FromBody] PatchAutomationTokenDto body,
+ [FromServices] IAuditService auditService,
+ CancellationToken ct)
+ {
+ PedanticallyEnsureAdmin();
+
+ var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
+ if (token is null) return NotFound();
+
+ var autoCleanupUsers = body.AutoCleanupUsers ?? token.AutoCleanupUsers;
+
+ // Switching cleanup off without naming a delay clears the delay, so it can be unset at all.
+ var autoCleanupAfter = body.AutoCleanupAfter ??
+ (body.AutoCleanupUsers == false ? null : token.AutoCleanupAfter);
+
+ if (autoCleanupUsers && autoCleanupAfter is null)
+ return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest);
+
+ if (body.Name is not null) token.Name = body.Name.Trim();
+ if (body.Types is not null) token.Types = [.. body.Types.Distinct()];
+ token.AutoCleanupUsers = autoCleanupUsers;
+ token.AutoCleanupAfter = autoCleanupAfter;
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+
+ await _db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync(
+ CurrentUser.Id,
+ action: AuditAction.AutomationTokenUpdated,
+ actorId: CurrentUser.Id,
+ metadata: new AutomationTokenUpdatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter),
+ cancellationToken: ct);
+
+ await transaction.CommitAsync(ct);
+
+ return Ok(AutomationTokenDto.FromEntity(token));
+ }
+}
diff --git a/API/Controller/Admin/AutomationTokenRotate.cs b/API/Controller/Admin/AutomationTokenRotate.cs
new file mode 100644
index 00000000..482e7a57
--- /dev/null
+++ b/API/Controller/Admin/AutomationTokenRotate.cs
@@ -0,0 +1,64 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.API.Controller.Admin.DTOs;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.Audit;
+using OpenShock.Common.Services.AutomationTokens;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Rotates the secret of an automation token. The new secret is only returned once, in this response
+ ///
+ [HttpPost("automationTokens/{id}/rotate")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task RotateAutomationToken(
+ [FromRoute] Guid id,
+ [FromServices] IAuditService auditService,
+ CancellationToken ct)
+ {
+ PedanticallyEnsureAdmin();
+
+ var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
+ if (token is null) return NotFound();
+
+ var secret = IAutomationTokenService.GenerateSecret();
+ token.TokenHash = HashingUtils.HashToken(secret);
+ token.LastUsedAt = null;
+ token.UseCount = 0;
+ token.LastRotatedAt = DateTime.UtcNow;
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+
+ await _db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync(
+ CurrentUser.Id,
+ action: AuditAction.AutomationTokenRotated,
+ actorId: CurrentUser.Id,
+ metadata: new AutomationTokenRotatedMetadata(token.Id, token.Name),
+ cancellationToken: ct);
+
+ await transaction.CommitAsync(ct);
+
+ // Accounts the token created stay linked to it (and to its cleanup schedule) across rotations,
+ // since the token id doesn't change.
+
+ return Ok(new CreatedAutomationTokenDto
+ {
+ Id = token.Id,
+ Name = token.Name,
+ Secret = secret,
+ Types = token.Types,
+ CreatedAt = token.CreatedAt,
+ LastRotatedAt = token.LastRotatedAt,
+ AutoCleanupUsers = token.AutoCleanupUsers,
+ AutoCleanupAfter = token.AutoCleanupAfter,
+ });
+ }
+}
diff --git a/API/Controller/Admin/DTOs/AutomationTokenDto.cs b/API/Controller/Admin/DTOs/AutomationTokenDto.cs
new file mode 100644
index 00000000..bef4b4d1
--- /dev/null
+++ b/API/Controller/Admin/DTOs/AutomationTokenDto.cs
@@ -0,0 +1,41 @@
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.Controller.Admin.DTOs;
+
+public sealed class AutomationTokenDto
+{
+ public required Guid Id { get; init; }
+ public required string Name { get; init; }
+ public required IReadOnlyList Types { get; init; }
+ public required DateTime CreatedAt { get; init; }
+ public DateTime? LastUsedAt { get; init; }
+ public DateTime? LastRotatedAt { get; init; }
+ public long UseCount { get; init; }
+ public bool AutoCleanupUsers { get; init; }
+ public TimeSpan? AutoCleanupAfter { get; init; }
+
+ public static AutomationTokenDto FromEntity(AutomationToken token) => new()
+ {
+ Id = token.Id,
+ Name = token.Name,
+ Types = token.Types,
+ CreatedAt = token.CreatedAt,
+ LastUsedAt = token.LastUsedAt,
+ LastRotatedAt = token.LastRotatedAt,
+ UseCount = token.UseCount,
+ AutoCleanupUsers = token.AutoCleanupUsers,
+ AutoCleanupAfter = token.AutoCleanupAfter,
+ };
+}
+
+public sealed class CreatedAutomationTokenDto
+{
+ public required Guid Id { get; init; }
+ public required string Name { get; init; }
+ public required string Secret { get; init; }
+ public required IReadOnlyList Types { get; init; }
+ public required DateTime CreatedAt { get; init; }
+ public DateTime? LastRotatedAt { get; init; }
+ public bool AutoCleanupUsers { get; init; }
+ public TimeSpan? AutoCleanupAfter { get; init; }
+}
diff --git a/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs b/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs
new file mode 100644
index 00000000..524376ed
--- /dev/null
+++ b/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs
@@ -0,0 +1,70 @@
+using System.ComponentModel.DataAnnotations;
+using OpenShock.Common.Constants;
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.Controller.Admin.DTOs;
+
+public sealed class CreateAutomationTokenDto : IValidatableObject
+{
+ [Required]
+ [MinLength(1)]
+ [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)]
+ public required string Name { get; init; }
+
+ [Required]
+ [MinLength(1)]
+ public required IReadOnlyList Types { get; init; }
+
+ ///
+ /// Whether the automated accounts this token creates are deleted once has
+ /// passed since they were created. Off by default; accounts the token didn't create are never deleted.
+ ///
+ public bool AutoCleanupUsers { get; init; } = false;
+
+ public TimeSpan? AutoCleanupAfter { get; init; }
+
+ public IEnumerable Validate(ValidationContext _)
+ {
+ if (string.IsNullOrWhiteSpace(Name))
+ yield return new ValidationResult(
+ $"{nameof(Name)} must not be blank.",
+ [nameof(Name)]);
+
+ if (AutoCleanupUsers && AutoCleanupAfter is null)
+ yield return new ValidationResult(
+ $"{nameof(AutoCleanupAfter)} is required when {nameof(AutoCleanupUsers)} is true.",
+ [nameof(AutoCleanupAfter)]);
+
+ if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero)
+ yield return new ValidationResult(
+ $"{nameof(AutoCleanupAfter)} must be positive.",
+ [nameof(AutoCleanupAfter)]);
+ }
+}
+
+public sealed class PatchAutomationTokenDto : IValidatableObject
+{
+ [MinLength(1)]
+ [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)]
+ public string? Name { get; init; }
+
+ [MinLength(1)]
+ public IReadOnlyList? Types { get; init; }
+
+ public bool? AutoCleanupUsers { get; init; }
+
+ public TimeSpan? AutoCleanupAfter { get; init; }
+
+ public IEnumerable Validate(ValidationContext _)
+ {
+ if (Name is not null && string.IsNullOrWhiteSpace(Name))
+ yield return new ValidationResult(
+ $"{nameof(Name)} must not be blank.",
+ [nameof(Name)]);
+
+ if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero)
+ yield return new ValidationResult(
+ $"{nameof(AutoCleanupAfter)} must be positive.",
+ [nameof(AutoCleanupAfter)]);
+ }
+}
diff --git a/API/Controller/Admin/GetUser.cs b/API/Controller/Admin/GetUser.cs
index 2571f56c..c13ce360 100644
--- a/API/Controller/Admin/GetUser.cs
+++ b/API/Controller/Admin/GetUser.cs
@@ -87,6 +87,8 @@ public sealed class AdminUserView
public required DateTimeOffset? ActivatedAt { get; init; }
+ public required Guid? CreatedByAutomationTokenId { get; init; }
+
public required AdminUserView_UserActivationRequest? ActivationRequest { get; init; }
public required AdminUserView_UserDeactivation? Deactivation { get; init; }
@@ -113,6 +115,7 @@ public sealed class AdminUserView
u.Roles,
u.CreatedAt,
u.ActivatedAt,
+ u.CreatedByAutomationTokenId,
Hubs = u.Devices.Select(hub =>
new AdminUserView_Hub
{
@@ -201,6 +204,7 @@ public sealed class AdminUserView
Roles = user.Roles,
CreatedAt = user.CreatedAt,
ActivatedAt = user.ActivatedAt,
+ CreatedByAutomationTokenId = user.CreatedByAutomationTokenId,
Hubs = user.Hubs,
ApiTokens = user.ApiTokens,
PasswordResets = user.PasswordResets,
diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs
index dda6408e..a5f2289a 100644
--- a/API/Controller/Tokens/ReportTokens.cs
+++ b/API/Controller/Tokens/ReportTokens.cs
@@ -9,7 +9,9 @@
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Errors;
using OpenShock.API.Services.Turnstile;
+using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Results;
+using OpenShock.Common.Services.AutomationTokens;
using OpenShock.Common.Services.Webhook;
using OpenShock.Internal.Common.Utils;
@@ -25,6 +27,7 @@ public sealed partial class TokensController
///
///
///
+ ///
///
///
/// The tokens were deleted if found
@@ -35,6 +38,7 @@ public sealed partial class TokensController
public async Task ReportTokens(
[FromBody] ReportTokensRequest body,
[FromServices] ICloudflareTurnstileService turnstileService,
+ [FromServices] IAutomationTokenService automationTokens,
[FromServices] IWebhookService webhookService,
CancellationToken cancellationToken)
{
@@ -49,6 +53,10 @@ public async Task ReportTokens(
return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError));
}
+ // The middleware already refused the token for a privileged session; this audits the use.
+ if (!await automationTokens.TryRecordUseAsync(CurrentUser.Id, AutomationTokenFlow.ReportTokens, cancellationToken))
+ return Problem(AutomationTokenError.NotAllowedForAccount);
+
var reportId = Guid.CreateVersion7();
int nAffected = 0;
diff --git a/API/Errors/AutomationTokenError.cs b/API/Errors/AutomationTokenError.cs
new file mode 100644
index 00000000..8e760d22
--- /dev/null
+++ b/API/Errors/AutomationTokenError.cs
@@ -0,0 +1,11 @@
+using System.Net;
+using OpenShock.Common.Problems;
+
+using OpenShock.Internal.Common.Problems;
+
+namespace OpenShock.API.Errors;
+
+public static class AutomationTokenError
+{
+ public static OpenShockProblem NotAllowedForAccount => new("AutomationToken.NotAllowedForAccount", "Automation tokens cannot be used with privileged accounts", HttpStatusCode.Forbidden);
+}
diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs
index de1c043d..47c7ef48 100644
--- a/API/Services/Account/AccountService.cs
+++ b/API/Services/Account/AccountService.cs
@@ -7,6 +7,7 @@
using OpenShock.Common.Options;
using OpenShock.Common.Results;
using OpenShock.Common.Services.Audit;
+using OpenShock.Common.Services.AutomationTokens;
using OpenShock.Common.Services.RedisPubSub;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Utils;
@@ -26,6 +27,7 @@ public sealed class AccountService : IAccountService
private readonly IRedisPubService _redisPubService;
private readonly ISessionService _sessionService;
private readonly IAuditService _auditService;
+ private readonly IAutomationTokenService _automationTokens;
private readonly MailOptions _mailOptions;
private readonly ILogger _logger;
@@ -36,14 +38,16 @@ public sealed class AccountService : IAccountService
/// Used to notify the email outbox delivery job that mail was enqueued.
///
///
+ /// The automation token this request carries, if any; an account it creates is linked to it.
/// Decides whether an activation link can ever reach the user.
///
- public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, MailOptions mailOptions, ILogger logger)
+ public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, IAutomationTokenService automationTokens, MailOptions mailOptions, ILogger logger)
{
_db = db;
_redisPubService = redisPubService;
_sessionService = sessionService;
_auditService = auditService;
+ _automationTokens = automationTokens;
_mailOptions = mailOptions;
_logger = logger;
}
@@ -91,7 +95,7 @@ private async Task IsEmailProviderBlacklisted(string email)
return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain);
}
- private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation)
+ private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation, Guid? createdByAutomationTokenId)
{
email = email.ToLowerInvariant();
@@ -106,7 +110,8 @@ private async Task CreateAccount(string email, string use
Id = Guid.CreateVersion7(),
Name = username,
Email = email,
- PasswordHash = HashingUtils.HashPassword(password)
+ PasswordHash = HashingUtils.HashPassword(password),
+ CreatedByAutomationTokenId = createdByAutomationTokenId
};
_db.Users.Add(user);
@@ -124,27 +129,40 @@ await _db.Users
}
///
- public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password)
+ public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password, CancellationToken cancellationToken = default)
{
// With mail disabled the activation email is never delivered, so an activation flow would leave
// the account permanently unusable. Activate on creation instead.
var mailEnabled = _mailOptions.IsEnabled;
- var accountCreate = await CreateAccount(email, username, password, !mailEnabled);
- if (accountCreate.Value is not User user || !mailEnabled) return accountCreate;
+ // An account an automation token creates must never exist without the audit entry for that use, so the
+ // account, its activation request and the audit entry commit together.
+ await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
+
+ // Created with an automation token, the account is linked to it on insert, which makes it an automated account.
+ var accountCreate = await CreateAccount(email, username, password, !mailEnabled, _automationTokens.Current?.Id);
+ if (accountCreate.Value is not User user) return accountCreate;
- // The real activation token is minted by the outbox delivery job at send time; here we record the
- // request (with a seeded hash) and durably enqueue the email.
- user.UserActivationRequest = new UserActivationRequest
+ if (mailEnabled)
{
- UserId = user.Id,
- TokenHash = SeedTokenHash()
- };
+ // The real activation token is minted by the outbox delivery job at send time; here we record the
+ // request (with a seeded hash) and durably enqueue the email.
+ user.UserActivationRequest = new UserActivationRequest
+ {
+ UserId = user.Id,
+ TokenHash = SeedTokenHash()
+ };
- _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username));
+ _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username));
- await _db.SaveChangesAsync();
- await NotifyEmailOutboxAsync();
+ await _db.SaveChangesAsync(cancellationToken);
+ }
+
+ await _automationTokens.RecordSignupAsync(user.Id, cancellationToken);
+
+ await transaction.CommitAsync(cancellationToken);
+
+ if (mailEnabled) await NotifyEmailOutboxAsync();
return user;
}
diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs
index 9a801d9c..069019bb 100644
--- a/API/Services/Account/IAccountService.cs
+++ b/API/Services/Account/IAccountService.cs
@@ -15,8 +15,9 @@ public interface IAccountService
///
///
///
+ ///
///
- public Task CreateAccountWithActivationFlowAsync(string email, string username, string password);
+ public Task CreateAccountWithActivationFlowAsync(string email, string username, string password, CancellationToken cancellationToken = default);
///
/// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction.
diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs
index 0df2e762..cc7bd4d5 100644
--- a/API/Services/Turnstile/CloudflareTurnstileService.cs
+++ b/API/Services/Turnstile/CloudflareTurnstileService.cs
@@ -1,6 +1,8 @@
using System.Net;
using OpenShock.API.Options;
+using OpenShock.Common.Extensions;
using OpenShock.Common.Results;
+using OpenShock.Common.OpenShockDb;
namespace OpenShock.API.Services.Turnstile;
@@ -11,13 +13,20 @@ public sealed class CloudflareTurnstileService : ICloudflareTurnstileService
private readonly HttpClient _httpClient;
private readonly TurnstileOptions _options;
private readonly IHostEnvironment _environment;
+ private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger _logger;
- public CloudflareTurnstileService(HttpClient httpClient, TurnstileOptions options, IHostEnvironment environment, ILogger logger)
+ public CloudflareTurnstileService(
+ HttpClient httpClient,
+ TurnstileOptions options,
+ IHostEnvironment environment,
+ IHttpContextAccessor httpContextAccessor,
+ ILogger logger)
{
_httpClient = httpClient;
_options = options;
_environment = environment;
+ _httpContextAccessor = httpContextAccessor;
_logger = logger;
}
@@ -44,6 +53,12 @@ public async Task> VerifyUserResponse
{
if (!_options.Enabled) return new Success();
+ // An admin-issued automation token resolved earlier in the pipeline counts as a Turnstile pass
+ // if it carries the Turnstile type. Controllers separately check, through IAutomationTokenService,
+ // that the account they act on is not privileged.
+ if (_httpContextAccessor.HttpContext?.IsBypassed(AutomationTokenType.Turnstile) ?? false)
+ return new Success();
+
if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse);
if (_environment.IsDevelopment() && responseToken == "dev-bypass")
diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs
index 3f1153d5..91d36e13 100644
--- a/Common/Constants/AuthConstants.cs
+++ b/Common/Constants/AuthConstants.cs
@@ -6,6 +6,7 @@ public static class AuthConstants
public const string UserSessionHeaderName = "OpenShockSession";
public const string ApiTokenHeaderName = "OpenShockToken";
public const string HubTokenHeaderName = "DeviceToken";
+ public const string AutomationTokenHeaderName = "X-OpenShock-Automation-Token";
public const int GeneratedTokenLength = 32;
public const int ApiTokenLength = 64;
diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs
index 938112b2..77e209ba 100644
--- a/Common/Extensions/HttpContextExtensions.cs
+++ b/Common/Extensions/HttpContextExtensions.cs
@@ -1,11 +1,52 @@
using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Http;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Constants;
+using OpenShock.Common.Services.AutomationTokens;
namespace OpenShock.Common.Extensions;
public static class HttpContextExtensions
{
+ public static bool TryGetAutomationTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.Request.Headers.TryGetValue(AuthConstants.AutomationTokenHeaderName, out var value) && !string.IsNullOrEmpty(value))
+ {
+ token = value!;
+ return true;
+ }
+
+ token = null;
+ return false;
+ }
+
+ ///
+ /// Stores the result of resolving the automation-token header. Called by the automation-token middleware.
+ ///
+ public static void SetResolvedAutomationToken(this HttpContext context, ResolvedAutomationToken resolved)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ context.SetItemByType(resolved);
+ }
+
+ ///
+ /// Returns the automation token resolved earlier in the pipeline, or null if no header was
+ /// present (or it did not resolve to a known token).
+ ///
+ public static ResolvedAutomationToken? GetResolvedAutomationToken(this HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ return context.GetItemByType();
+ }
+
+ ///
+ /// Returns true if the request presented an automation token that grants .
+ ///
+ public static bool IsBypassed(this HttpContext context, AutomationTokenType type) =>
+ context.GetResolvedAutomationToken()?.Types.Contains(type) ?? false;
+
// FullName is only null for open generic parameters, which a closed T never is.
private static string ItemKeyOf() => typeof(T).FullName!;
diff --git a/Common/Middleware/AutomationTokenMiddleware.cs b/Common/Middleware/AutomationTokenMiddleware.cs
new file mode 100644
index 00000000..0a6fb02c
--- /dev/null
+++ b/Common/Middleware/AutomationTokenMiddleware.cs
@@ -0,0 +1,119 @@
+using System.Security.Claims;
+using OpenShock.Common.Extensions;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.AutomationTokens;
+using Microsoft.Extensions.Logging;
+
+namespace OpenShock.Common.Middleware;
+
+///
+/// Resolves the X-OpenShock-Automation-Token header (if present) into a
+/// stored on . Downstream guards (rate limiter partition selectors,
+/// the turnstile service, controllers needing post-auth user linkage) read the cached value
+/// synchronously and filter on the type they care about.
+///
+/// Runs before UseRateLimiter so the rate limiter can honor the bypass for the very same request.
+///
+public sealed class AutomationTokenMiddleware
+{
+ private readonly RequestDelegate _next;
+
+ public AutomationTokenMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ public async Task InvokeAsync(HttpContext context, IAutomationTokenService automationTokens, ILogger logger)
+ {
+ if (!context.TryGetAutomationTokenFromHeader(out var secret))
+ {
+ await _next(context);
+ return;
+ }
+
+ var resolved = await automationTokens.ResolveAsync(secret, context.RequestAborted);
+ if (resolved is null)
+ {
+ // A presented-but-unmatched token is either a stale or rotated secret, or someone probing for one.
+ LogUnmatched(logger);
+ await _next(context);
+ return;
+ }
+
+ // An automation token never applies to a privileged account, so it is dropped for one, rate limits
+ // and Turnstile included. Flows that pick the account from the request body (login, password reset)
+ // check the same rule.
+ if (await IsPrivilegedCallerAsync(context, automationTokens))
+ {
+ logger.LogWarning(
+ "Automation token {AutomationTokenName} ({AutomationTokenId}) refused for a privileged account on {Method} {Path}",
+ ForLog(resolved.Name), resolved.Id, ForLog(context.Request.Method), ForLog(context.Request.Path.Value));
+ await _next(context);
+ return;
+ }
+
+ context.SetResolvedAutomationToken(resolved);
+ automationTokens.RecordRequest(resolved);
+
+ // A credential that switches off Turnstile and rate limiting should never be used without
+ // leaving a trace. Logged at warning so it stands out in a production log, and the secret
+ // itself is never written, only which token was used, what it disabled, and for what.
+ logger.LogWarning(
+ "Automation token {AutomationTokenName} ({AutomationTokenId}) accepted for {Types} on {Method} {Path} from {RemoteIp}",
+ ForLog(resolved.Name), resolved.Id, string.Join(",", resolved.Types), ForLog(context.Request.Method),
+ ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress);
+
+ await _next(context);
+ }
+
+ private static async Task IsPrivilegedCallerAsync(HttpContext context, IAutomationTokenService automationTokens)
+ {
+ // Authentication already ran for the default scheme, so a cookie session's roles are on its claims.
+ if (context.User.TryGetOpenShockUserIdentity() is { } identity)
+ return PrivilegedRoles.Any(identity.GetRoles());
+
+ // Authorization also ran, so an endpoint's own scheme (hub or API token) may have authenticated the
+ // request too. Those identities carry the owning account's id but no roles, so look the roles up.
+ if (context.User.Identities.FirstOrDefault(x => x.IsAuthenticated) is { } other &&
+ Guid.TryParse(other.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var userId))
+ return await automationTokens.IsUserPrivilegedAsync(userId, context.RequestAborted);
+
+ // Not authenticated yet, as on an anonymous endpoint, but the header still names the account the
+ // caller acts as.
+ if (context.TryGetApiTokenFromHeader(out var apiToken))
+ return await automationTokens.IsApiTokenOwnerPrivilegedAsync(apiToken, context.RequestAborted);
+
+ return false;
+ }
+
+ // An unmatched token needs no credential to present, so the warning it produces is volume an
+ // unauthenticated caller controls. Keep the audit signal, but collapse it to at most one line
+ // per window and report how many attempts that line stands for.
+ private static readonly TimeSpan UnmatchedWarningInterval = TimeSpan.FromMinutes(1);
+ private static long _unmatchedWindowStartTicks;
+ private static long _unmatchedAttempts;
+
+ private static void LogUnmatched(ILogger logger)
+ {
+ Interlocked.Increment(ref _unmatchedAttempts);
+
+ var now = Environment.TickCount64;
+ var windowStart = Interlocked.Read(ref _unmatchedWindowStartTicks);
+ if (now - windowStart < UnmatchedWarningInterval.TotalMilliseconds) return;
+
+ // Loser of the race has nothing to report; the winner owns this window's line.
+ if (Interlocked.CompareExchange(ref _unmatchedWindowStartTicks, now, windowStart) != windowStart) return;
+
+ var attempts = Interlocked.Exchange(ref _unmatchedAttempts, 0);
+
+ // Deliberately free of request data: the path, method and header are all caller-controlled,
+ // and this line exists to say that probing is happening, not to echo the probe back.
+ logger.LogWarning(
+ "Automation token presented but matched nothing ({Attempts} attempt(s) in the last {Interval})",
+ attempts, UnmatchedWarningInterval);
+ }
+
+ // Request data would otherwise reach the log verbatim, where a newline in it forges a log line.
+ private static string ForLog(string? value) =>
+ string.IsNullOrEmpty(value) ? string.Empty : value.ReplaceLineEndings(string.Empty);
+}
diff --git a/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs
new file mode 100644
index 00000000..c6f41cae
--- /dev/null
+++ b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs
@@ -0,0 +1,1767 @@
+//
+using System;
+using System.Collections.Generic;
+using System.Net;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using OpenShock.Common.OpenShockDb;
+
+#nullable disable
+
+namespace OpenShock.Common.Migrations;
+
+[DbContext(typeof(MigrationOpenShockContext))]
+[Migration("20260930204951_AddAutomationTokens")]
+partial class AddAutomationTokens
+{
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False")
+ .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "bcrypt_enhanced", "pbkdf2" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" });
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ var id = b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(id);
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b =>
+ {
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("activated_at");
+
+ b.Property("ApiTokenCount")
+ .HasColumnType("integer")
+ .HasColumnName("api_token_count");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedByAutomationTokenId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_automation_token_id");
+
+ b.Property("DeactivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deactivated_at");
+
+ b.Property("DeactivatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_by_user_id");
+
+ b.Property("DeviceCount")
+ .HasColumnType("integer")
+ .HasColumnName("device_count");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("email");
+
+ b.Property("EmailChangeRequestCount")
+ .HasColumnType("integer")
+ .HasColumnName("email_change_request_count");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("name");
+
+ b.Property("NameChangeRequestCount")
+ .HasColumnType("integer")
+ .HasColumnName("name_change_request_count");
+
+ b.Property("PasswordHashType")
+ .HasColumnType("character varying")
+ .HasColumnName("password_hash_type");
+
+ b.Property("PasswordResetCount")
+ .HasColumnType("integer")
+ .HasColumnName("password_reset_count");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("role_type[]")
+ .HasColumnName("roles");
+
+ b.Property("ShockerControlLogCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_control_log_count");
+
+ b.Property("ShockerCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_count");
+
+ b.Property("ShockerPublicShareCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_public_share_count");
+
+ b.Property("ShockerUserShareCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_user_share_count");
+
+ b.ToTable((string)null);
+
+ b.ToView("admin_users_view", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedByIp")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("created_by_ip");
+
+ b.Property("LastUsed")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property>("Permissions")
+ .IsRequired()
+ .HasColumnType("permission_type[]")
+ .HasColumnName("permissions");
+
+ b.Property("ShockerControlDurationMax")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(65535)
+ .HasColumnName("shocker_control_duration_max");
+
+ b.Property("ShockerControlDurationMin")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(300)
+ .HasColumnName("shocker_control_duration_min");
+
+ b.Property("ShockerControlDurationMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("control_limit_mode")
+ .HasDefaultValue(ControlLimitMode.Clamp)
+ .HasColumnName("shocker_control_duration_mode");
+
+ b.Property("ShockerControlIntensityMax")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("smallint")
+ .HasDefaultValue((byte)100)
+ .HasColumnName("shocker_control_intensity_max");
+
+ b.Property("ShockerControlIntensityMin")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("smallint")
+ .HasDefaultValue((byte)0)
+ .HasColumnName("shocker_control_intensity_min");
+
+ b.Property("ShockerControlIntensityMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("control_limit_mode")
+ .HasDefaultValue(ControlLimitMode.Clamp)
+ .HasColumnName("shocker_control_intensity_mode");
+
+ b.Property("ShockerControlPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("shocker_control_paused");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("ValidUntil")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("valid_until");
+
+ b.HasKey("Id")
+ .HasName("api_tokens_pkey");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ValidUntil");
+
+ b.ToTable("api_tokens");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AffectedCount")
+ .HasColumnType("integer")
+ .HasColumnName("affected_count");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IpAddress")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("ip_address");
+
+ b.Property("IpCountry")
+ .HasColumnType("text")
+ .HasColumnName("ip_country");
+
+ b.Property("SubmittedCount")
+ .HasColumnType("integer")
+ .HasColumnName("submitted_count");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("api_token_reports_pkey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("api_token_reports");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AutoCleanupAfter")
+ .HasColumnType("interval")
+ .HasColumnName("auto_cleanup_after");
+
+ b.Property("AutoCleanupUsers")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("auto_cleanup_users");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("LastRotatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_rotated_at");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property>("Types")
+ .IsRequired()
+ .HasColumnType("automation_token_type[]")
+ .HasColumnName("types");
+
+ b.Property("UseCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("use_count");
+
+ b.HasKey("Id")
+ .HasName("automation_tokens_pkey");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.ToTable("automation_tokens");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name")
+ .UseCollation("C");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Type")
+ .HasColumnType("configuration_value_type")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("Name")
+ .HasName("configuration_pkey");
+
+ b.ToTable("configuration");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("token")
+ .UseCollation("C");
+
+ b.HasKey("Id")
+ .HasName("devices_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("devices");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b =>
+ {
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("UpdateId")
+ .HasColumnType("integer")
+ .HasColumnName("update_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Message")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("message");
+
+ b.Property("Status")
+ .HasColumnType("ota_update_status")
+ .HasColumnName("status");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("version");
+
+ b.HasKey("DeviceId", "UpdateId")
+ .HasName("device_ota_updates_pkey");
+
+ b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx");
+
+ b.ToTable("device_ota_updates");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("WebhookId")
+ .HasColumnType("bigint")
+ .HasColumnName("webhook_id");
+
+ b.Property("WebhookToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("webhook_token");
+
+ b.HasKey("Name")
+ .HasName("discord_webhooks_pkey");
+
+ b.ToTable("discord_webhooks");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AttemptCount")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("attempt_count");
+
+ b.Property("CoalesceKey")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("coalesce_key");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("FailedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("failed_at");
+
+ b.Property("LastError")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasColumnName("last_error");
+
+ b.Property("NextAttemptAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("next_attempt_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property>("Payload")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload");
+
+ b.Property("Recipient")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("recipient");
+
+ b.Property("RecipientName")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("recipient_name");
+
+ b.Property("SentAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("sent_at");
+
+ b.Property("Status")
+ .HasColumnType("email_status")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .HasColumnType("email_type")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("email_outbox_pkey");
+
+ b.HasIndex("CoalesceKey");
+
+ b.HasIndex("Recipient");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("email_outbox");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("domain")
+ .UseCollation("ndcoll");
+
+ b.HasKey("Id")
+ .HasName("email_provider_blacklist_pkey");
+
+ var index = b.HasIndex("Domain")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" });
+
+ b.ToTable("email_provider_blacklist");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id")
+ .HasName("public_shares_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.ToTable("public_shares");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b =>
+ {
+ b.Property("PublicShareId")
+ .HasColumnType("uuid")
+ .HasColumnName("public_share_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("Cooldown")
+ .HasColumnType("integer")
+ .HasColumnName("cooldown");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("PublicShareId", "ShockerId")
+ .HasName("public_share_shockers_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("public_share_shockers");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("Model")
+ .HasColumnType("shocker_model_type")
+ .HasColumnName("model");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("RfId")
+ .HasColumnType("integer")
+ .HasColumnName("rf_id");
+
+ b.HasKey("Id")
+ .HasName("shockers_pkey");
+
+ b.HasIndex("DeviceId");
+
+ b.ToTable("shockers");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ControlledByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("controlled_by_user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CustomName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("custom_name");
+
+ b.Property("Duration")
+ .HasColumnType("bigint")
+ .HasColumnName("duration");
+
+ b.Property("Intensity")
+ .HasColumnType("smallint")
+ .HasColumnName("intensity");
+
+ b.Property("LiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("live_control");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("Type")
+ .HasColumnType("control_type")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("shocker_control_logs_pkey");
+
+ b.HasIndex("ControlledByUserId");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("shocker_control_logs");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.HasKey("Id")
+ .HasName("shocker_share_codes_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("shocker_share_codes");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("activated_at");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedByAutomationTokenId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_automation_token_id");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("name")
+ .UseCollation("ndcoll");
+
+ b.Property("PasswordHash")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("password_hash")
+ .UseCollation("C");
+
+ b.Property>("Roles")
+ .IsRequired()
+ .HasColumnType("role_type[]")
+ .HasColumnName("roles");
+
+ b.Property("SecurityStamp")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("security_stamp")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.HasKey("Id")
+ .HasName("users_pkey");
+
+ b.HasIndex("CreatedByAutomationTokenId");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ var index = b.HasIndex("Name")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" });
+
+ b.ToTable("users");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("EmailSendAttempts")
+ .HasColumnType("integer")
+ .HasColumnName("email_send_attempts");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.HasKey("UserId")
+ .HasName("user_activation_requests_pkey");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.ToTable("user_activation_requests");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Action")
+ .HasColumnType("audit_action")
+ .HasColumnName("action");
+
+ b.Property("ActorId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IpAddress")
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ip_address");
+
+ b.Property("Metadata")
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata");
+
+ b.Property("Reason")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("reason");
+
+ b.Property("UserAgent")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasColumnName("user_agent");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_audit_logs_pkey");
+
+ b.HasIndex("ActorId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_audit_logs");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b =>
+ {
+ b.Property("DeactivatedUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeactivatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_by_user_id");
+
+ b.Property("DeleteLater")
+ .HasColumnType("boolean")
+ .HasColumnName("delete_later");
+
+ b.Property("UserModerationId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_moderation_id");
+
+ b.HasKey("DeactivatedUserId")
+ .HasName("user_deactivations_pkey");
+
+ b.HasIndex("DeactivatedByUserId");
+
+ b.ToTable("user_deactivations");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("NewEmail")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email_new");
+
+ b.Property("OldEmail")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email_old");
+
+ b.Property("SecurityStampAtCreate")
+ .HasColumnType("uuid")
+ .HasColumnName("security_stamp_at_create");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_email_changes_pkey");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UsedAt");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_email_changes");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("MatchType")
+ .HasColumnType("match_type_enum")
+ .HasColumnName("match_type");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("value")
+ .UseCollation("ndcoll");
+
+ b.HasKey("Id")
+ .HasName("user_name_blacklist_pkey");
+
+ var index = b.HasIndex("Value")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" });
+
+ b.ToTable("user_name_blacklist");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b =>
+ {
+ var id = b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(id);
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("OldName")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("old_name");
+
+ b.HasKey("Id", "UserId")
+ .HasName("user_name_changes_pkey");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("OldName");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_name_changes");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b =>
+ {
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key")
+ .UseCollation("C");
+
+ b.Property("ExternalId")
+ .HasColumnType("text")
+ .HasColumnName("external_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("ProviderKey", "ExternalId")
+ .HasName("user_oauth_connections_pkey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_oauth_connections");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("SecurityStampAtCreate")
+ .HasColumnType("uuid")
+ .HasColumnName("security_stamp_at_create");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property