From dde3df999e8cedfcffe66c0224ec1b2c49654671 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Tue, 16 Jun 2026 12:27:54 -0400 Subject: [PATCH] feat(auth): OAuth device grant backend + consent page (AI-050a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8628 Device Authorization Grant so the headless MCP CLI can obtain a per-user TextStack JWT (replaces the AI-048a static shared-token interim). - DeviceAuthorization entity + migration: device_code stored HASHED (SHA256-hex, unique index), short user_code (Crockford base32, no ambiguous chars), nullable UserId until approval, status/expiry, FK OnDelete(SetNull). - 3 endpoints under /auth/device: code (mint), token (CLI polls, RFC 8628 errors authorization_pending/expired_token/access_denied), approve (authed web user consents). Reuses the SAME GenerateAccessToken + CreateRefreshTokenAsync as login — full per-user JWT, single-use, 10-min TTL, rate-limited (code 5/min, token 12/min, approve 10/min). TimeProvider injected for testable expiry. - Consent page /device (outside lang routes): logged-in user enters/ confirms the user_code, sees an explicit 'acts as YOU' anti-phishing warning + scope, Approve/Deny. Security (adversarial review, 0 P1): device_code only stored hashed + never logged; redeem only ever mints for the consenting approver; approve requires an authed session; unknown device_code → expired_token (no enumeration oracle). QA P2 fixes: approve/deny scoped to the live pending row (user_code recurs across history — stale terminal rows now invisible); Deny button wired to /auth/device/deny. DeviceFlowTokenProvider (CLI side) + token cache = AI-050b. 526 unit green (20 DeviceCodes helper tests) + DB-gated integration tests for the flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 + apps/web/src/App.tsx | 4 + apps/web/src/api/auth.ts | 23 + apps/web/src/locales/en.json | 25 + apps/web/src/pages/DeviceVerifyPage.tsx | 181 + .../src/Api/Endpoints/DeviceAuthEndpoints.cs | 136 + backend/src/Api/Program.cs | 36 + backend/src/Application/Auth/AuthService.cs | 169 +- .../src/Application/Auth/DeviceAuthResults.cs | 46 + backend/src/Application/Auth/DeviceCodes.cs | 50 + .../Common/Interfaces/IAppDbContext.cs | 1 + .../src/Application/DependencyInjection.cs | 4 + .../Domain/Entities/DeviceAuthorization.cs | 46 + ...6160544_AddDeviceAuthorization.Designer.cs | 4756 +++++++++++++++++ .../20260616160544_AddDeviceAuthorization.cs | 69 + .../Migrations/AppDbContextModelSnapshot.cs | 76 + .../Persistence/AppDbContext.User.cs | 14 + .../Persistence/AppDbContext.cs | 1 + tests/TextStack.AiEvals/CapturingDb.cs | 1 + .../DeviceAuthEndpointTests.cs | 308 ++ tests/TextStack.UnitTests/DeviceCodesTests.cs | 145 + 21 files changed, 6102 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/pages/DeviceVerifyPage.tsx create mode 100644 backend/src/Api/Endpoints/DeviceAuthEndpoints.cs create mode 100644 backend/src/Application/Auth/DeviceAuthResults.cs create mode 100644 backend/src/Application/Auth/DeviceCodes.cs create mode 100644 backend/src/Domain/Entities/DeviceAuthorization.cs create mode 100644 backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.Designer.cs create mode 100644 backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.cs create mode 100644 tests/TextStack.IntegrationTests/DeviceAuthEndpointTests.cs create mode 100644 tests/TextStack.UnitTests/DeviceCodesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index aa416afb..86e0573d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +### Phase 8 — Device Authorization Grant backend (AI-050a) (2026-06-16) + +Backend for the OAuth 2.0 **Device Authorization Grant (RFC 8628)** so the headless MCP CLI (AI-050b) can obtain a per-user TextStack JWT without a browser redirect. This is the BACKEND slice; the consent page lives in `apps/web` (built concurrently by the frontend agent). + +- **New `DeviceAuthorization` entity + migration** (`AddDeviceAuthorization`). Mirrors the `PasswordResetToken` precedent: the long `device_code` secret is stored **SHA256-hex hashed**, never plain. Columns: `device_code_hash` (unique, maxlen 128), `user_code` (maxlen 16), `user_id` (nullable until approved, FK `OnDelete(SetNull)`), `status` (pending|approved|denied|expired, maxlen 16), `expires_at`, `interval_seconds` (default 5), `created_at`, `consumed_at`. Indexes: unique on `device_code_hash`, **filtered** `user_code WHERE status='pending'`, and `expires_at`. Exposed on `IAppDbContext` + `AppDbContext`. +- **3 RFC-8628 endpoints** under `/auth/device` (`DeviceAuthEndpoints.cs`), snake_case fields/errors per §3.2/§3.5, plus a `deny`: + - `POST /auth/device/code` (public, rate-limited `device-code` 5/min/IP) → `{ device_code, user_code, verification_uri: "/device", verification_uri_complete, expires_in: 600, interval: 5 }`. Public base from config key **`App:BaseUrl`** (default `https://textstack.app`). + - `POST /auth/device/token` (public, `device-token` 12/min/IP) — body `{ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code }`. Approved → `200 { access_token, refresh_token, token_type:"Bearer", user }`; otherwise `400 { error: "authorization_pending" | "expired_token" | "access_denied" }`. Unknown device_code → `expired_token` (no enumeration). + - `POST /auth/device/approve` (**authed** via cookie session, `device-approve` 10/min/IP) — body `{ user_code }` → `200 { status:"ok" }` or `400 { error: "invalid_user_code" | "expired_user_code" | "user_code_already_used" }`. `POST /auth/device/deny` included (sets status=denied). +- **Reuses existing JWT issuance** — `RedeemDeviceCodeAsync` mints tokens via the SAME `GenerateAccessToken` + `CreateRefreshTokenAsync` as the login path (no re-implemented JWT). **`TimeProvider`** injected into `AuthService` so device expiry is testable with a fake clock (`TimeProvider.System` registered in DI). Pure helpers extracted to `DeviceCodes` (user_code gen/normalize, SHA256 hash, secure token) for unit testing. +- **Security**: device_code hashed at rest; **single-use** (redeem flips status off pending + sets `consumed_at`, so a second poll → `expired_token`); approval requires an authenticated session; short **10-min TTL**; per-IP rate limits. `user_code` = Crockford base32 minus ambiguous chars (no I/O/0/1), 8 chars grouped `XXXX-XXXX` — its entropy is deliberately NOT load-bearing. +- **Frontend contract** (api method added in `apps/web/src/api/auth.ts`, page owned by frontend agent): `approveDevice(userCode)` → `POST /auth/device/approve`, body `{ user_code }`, `credentials:'include'`. Companion `denyDevice(userCode)` → `POST /auth/device/deny` (same body/credentials/error-union); the consent page's **Deny/Cancel now calls the backend** (when authed + code present) before showing the denied state, so the pending row is rejected and the CLI poll transitions to `access_denied` instead of looping on `authorization_pending`. +- **QA fix (P2#1) — approve/deny scoped to the live pending row.** `ApproveDeviceAsync`/`DenyDeviceAsync` now look up `UserCode == normalized && Status == Pending` (was an unscoped `FirstOrDefault` with no status/ordering). Because the `user_code` index is FILTERED on `status='pending'` (not unique), the same 8-char code legitimately recurs across history; the old unscoped lookup could return a stale terminal row and wrongly yield `AlreadyUsed`/`Expired` for an approvable live flow. Lazy-expiry preserved (a past-deadline pending row still reports `Expired`); stale terminal rows of the same code are now invisible to approve/deny. +- **Tests**: 9 unit tests (`DeviceCodesTests` — user_code shape/alphabet/entropy, normalization, deterministic SHA256, secure-token uniqueness) + 10 integration tests (`DeviceAuthEndpointTests` — RFC fields, authorization_pending, anon-approve 401, approve→poll tokens, single-use, unknown-code expired_token; hashed-storage + expired-seed + **same-code-scoping approve/deny** guarded by `TEST_DB_CONNECTION`). The same-code-scoping cases seed an OLDER terminal row sharing the live row's `user_code` and prove approve/deny still bind the live pending row (poll → tokens / `access_denied`). The CLI provider is AI-050b. + ### Phase 8 — MCP read-tool surface (AI-048a) (2026-06-16) Second Phase 8 slice (2a/7): appends **5 READ tools** to the AI-047 `McpToolCatalog`, adds an interim auth-token provider for the user-scoped ones, and lifts AI-047's inline error handling into a shared wrapper. The bridge stays thin/stateless — each tool is still just a descriptor + a handler that does validation + mapping over the existing public API. The `save_highlight` WRITE tool is **deferred to AI-048b** (after OAuth, AI-050). diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 06092442..9caa8f01 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,6 +21,7 @@ import { TermsPage } from './pages/TermsPage' import { DmcaPage } from './pages/DmcaPage' import { ContactPage } from './pages/ContactPage' import { ResetPasswordPage } from './pages/ResetPasswordPage' +import { DeviceVerifyPage } from './pages/DeviceVerifyPage' import { SitemapPage } from './pages/SitemapPage' import { NotFoundPage } from './pages/NotFoundPage' // User-only / heavy routes — lazy so they ship in separate chunks. @@ -130,6 +131,9 @@ function AppRoutes() { return ( } /> + {/* Device Authorization Grant consent (AI-050a) — MCP CLI points here at a + fixed, language-less URL (/device?code=XXXX-XXXX), so mount top-level. */} + } /> {/* Redirect legacy URLs without language prefix */} } /> } /> diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 64374f41..a2713c5b 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -102,6 +102,29 @@ export async function getCurrentUser(): Promise { return authFetch('/auth/me') } +// Device Authorization Grant (RFC 8628, AI-050a) — consent page approves a +// CLI's user_code from the authenticated browser session. authFetch sends +// credentials:'include', so the session cookie authenticates the approval. +export type DeviceApproveError = + | 'invalid_user_code' + | 'expired_user_code' + | 'user_code_already_used' + | 'invalid_request' + +export async function approveDevice(userCode: string): Promise { + await authFetch('/auth/device/approve', { + method: 'POST', + body: JSON.stringify({ user_code: userCode }), + }) +} + +export async function denyDevice(userCode: string): Promise { + await authFetch('/auth/device/deny', { + method: 'POST', + body: JSON.stringify({ user_code: userCode }), + }) +} + // Profile API export interface UpdateProfilePayload { name?: string | null diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index d20dcb22..f756e70e 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -282,6 +282,31 @@ "auth": { "progressSavedToast": "Your reading progress and saved words were kept." }, + "deviceVerify": { + "title": "Connect a device", + "codeLabel": "Enter the code shown by your MCP client", + "codePlaceholder": "XXXX-XXXX", + "continue": "Continue", + "signInTitle": "Sign in to continue", + "signInText": "Sign in to your TextStack account to approve this connection.", + "signInBtn": "Sign in", + "consentTitle": "Approve connection", + "consentLead": "TextStack MCP wants to connect to your library as {{email}}.", + "consentScope": "It will be able to read your highlights, vocabulary, and ask questions about your books — and (soon) save highlights — the same access as this website.", + "consentWarning": "Only approve this if you started a login from your own MCP client.", + "approve": "Approve", + "cancel": "Cancel", + "submitting": "Approving...", + "deniedTitle": "Request canceled", + "deniedText": "No access was granted. You can close this page.", + "successTitle": "✓ Connected", + "successText": "Return to your MCP client or terminal — you can close this page now.", + "errorInvalidCode": "That code wasn't found — check it and try again.", + "errorExpired": "This code expired — start a new login from your MCP client.", + "errorAlreadyUsed": "This code was already used.", + "errorGeneric": "Something went wrong. Please try again.", + "errorNotSignedIn": "Please sign in first." + }, "common": { "loading": "Loading...", "noBooksYet": "No books available yet.", diff --git a/apps/web/src/pages/DeviceVerifyPage.tsx b/apps/web/src/pages/DeviceVerifyPage.tsx new file mode 100644 index 00000000..a472c66b --- /dev/null +++ b/apps/web/src/pages/DeviceVerifyPage.tsx @@ -0,0 +1,181 @@ +import { useState, FormEvent, useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' +import { approveDevice, denyDevice, DeviceApproveError } from '../api/auth' +import { useAuth } from '../context/AuthContext' +import { useTranslation } from '../hooks/useTranslation' + +/** + * OAuth 2.0 Device Authorization Grant (RFC 8628) consent gate (AI-050a). + * The MCP CLI prints a user_code and points the user at /device(?code=XXXX-XXXX). + * A signed-in TextStack user confirms the code and approves the CLI's access. + * Mounted at top-level (no /:lang prefix) so the CLI's stable URL works. + */ + +/** Strip everything but [A-Z0-9], then format as XXXX-XXXX for display. */ +function formatUserCode(raw: string): string { + const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8) + if (clean.length <= 4) return clean + return `${clean.slice(0, 4)}-${clean.slice(4)}` +} + +/** What we send to the backend: trimmed, uppercased; backend normalizes further. */ +function normalizeForSubmit(display: string): string { + return display.trim().toUpperCase() +} + +/** Map the thrown auth error (status + message=code) to an i18n key. */ +function errorKey(err: unknown): string { + const e = err as { status?: number; message?: string } + if (e?.status === 401) return 'deviceVerify.errorNotSignedIn' + const code = e?.message as DeviceApproveError | undefined + switch (code) { + case 'invalid_user_code': + return 'deviceVerify.errorInvalidCode' + case 'expired_user_code': + return 'deviceVerify.errorExpired' + case 'user_code_already_used': + return 'deviceVerify.errorAlreadyUsed' + default: + return 'deviceVerify.errorGeneric' + } +} + +export function DeviceVerifyPage() { + const [params] = useSearchParams() + const { t } = useTranslation() + const { isAuthenticated, user, openAuthModal } = useAuth() + + const initialCode = useMemo(() => formatUserCode(params.get('code') || ''), [params]) + const [code, setCode] = useState(initialCode) + const [submitting, setSubmitting] = useState(false) + const [errorMsg, setErrorMsg] = useState('') + const [success, setSuccess] = useState(false) + const [denied, setDenied] = useState(false) + + const handleApprove = async (e: FormEvent) => { + e.preventDefault() + setErrorMsg('') + const submitCode = normalizeForSubmit(code) + if (!submitCode) { + setErrorMsg(t('deviceVerify.errorInvalidCode')) + return + } + setSubmitting(true) + try { + await approveDevice(submitCode) + setSuccess(true) + } catch (err) { + setErrorMsg(t(errorKey(err))) + } finally { + setSubmitting(false) + } + } + + // Deny/Cancel: when authenticated with a code present, actually deny the + // pending row server-side so the CLI stops polling and gets access_denied. + // Otherwise (not signed in or no code) there's nothing to deny — local cancel. + const handleDeny = async () => { + setErrorMsg('') + const submitCode = normalizeForSubmit(code) + if (!isAuthenticated || !submitCode) { + setDenied(true) + return + } + setSubmitting(true) + try { + await denyDevice(submitCode) + setDenied(true) + } catch (err) { + setErrorMsg(t(errorKey(err))) + } finally { + setSubmitting(false) + } + } + + // --- Success --------------------------------------------------------------- + if (success) { + return ( +
+
+

{t('deviceVerify.successTitle')}

+

{t('deviceVerify.successText')}

+
+
+ ) + } + + // --- Denied ---------------------------------------------------------------- + if (denied) { + return ( +
+
+

{t('deviceVerify.deniedTitle')}

+

{t('deviceVerify.deniedText')}

+
+
+ ) + } + + // --- Not signed in: gate behind login ------------------------------------- + // The AuthModal stays on this same URL (incl. ?code=), so after sign-in the + // page re-renders into the consent state with the code preserved. + if (!isAuthenticated) { + return ( +
+
+

{t('deviceVerify.signInTitle')}

+

{t('deviceVerify.signInText')}

+ +
+
+ ) + } + + // --- Signed in: confirm code + consent ------------------------------------ + return ( +
+
+

{t('deviceVerify.consentTitle')}

+

+ {t('deviceVerify.consentLead', { email: user?.email || '' })} +

+

{t('deviceVerify.consentScope')}

+

+ {t('deviceVerify.consentWarning')} +

+
+ + setCode(formatUserCode(e.target.value))} + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + autoFocus={!code} + /> + {errorMsg &&

{errorMsg}

} + + +
+
+
+ ) +} diff --git a/backend/src/Api/Endpoints/DeviceAuthEndpoints.cs b/backend/src/Api/Endpoints/DeviceAuthEndpoints.cs new file mode 100644 index 00000000..8b36aff3 --- /dev/null +++ b/backend/src/Api/Endpoints/DeviceAuthEndpoints.cs @@ -0,0 +1,136 @@ +using System.Text.Json.Serialization; +using Api.Extensions; +using Application.Auth; +using Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Endpoints; + +/// +/// OAuth 2.0 Device Authorization Grant (RFC 8628) — AI-050a. +/// Lets the headless MCP CLI obtain a per-user TextStack JWT: +/// 1. CLI POST /auth/device/code → device_code + user_code + verification_uri +/// 2. User POST /auth/device/approve (authed browser) with user_code +/// 3. CLI POST /auth/device/token (polls) → access_token + refresh_token +/// Field/error names follow RFC 8628 §3.2 / §3.5 (snake_case). +/// +public static class DeviceAuthEndpoints +{ + private const string GrantType = "urn:ietf:params:oauth:grant-type:device_code"; + + public static void MapDeviceAuthEndpoints(this WebApplication app) + { + var group = app.MapGroup("/auth/device").WithTags("Auth"); + + group.MapPost("/code", RequestDeviceCode).WithName("DeviceRequestCode").RequireRateLimiting("device-code"); + group.MapPost("/token", PollDeviceToken).WithName("DevicePollToken").RequireRateLimiting("device-token"); + group.MapPost("/approve", ApproveDevice).WithName("DeviceApprove").RequireRateLimiting("device-approve"); + group.MapPost("/deny", DenyDevice).WithName("DeviceDeny").RequireRateLimiting("device-approve"); + } + + private static async Task RequestDeviceCode( + AuthService authService, + IConfiguration config, + CancellationToken ct) + { + var result = await authService.CreateDeviceAuthorizationAsync(ct); + + var baseUrl = (config["App:BaseUrl"] ?? "https://textstack.app").TrimEnd('/'); + var verificationUri = $"{baseUrl}/device"; + var verificationUriComplete = $"{verificationUri}?code={Uri.EscapeDataString(result.UserCode)}"; + + return Results.Ok(new + { + device_code = result.DeviceCode, + user_code = result.UserCode, + verification_uri = verificationUri, + verification_uri_complete = verificationUriComplete, + expires_in = result.ExpiresInSeconds, + interval = result.IntervalSeconds + }); + } + + private static async Task PollDeviceToken( + [FromBody] DeviceTokenRequest request, + AuthService authService, + CancellationToken ct) + { + if (!string.Equals(request.GrantType, GrantType, StringComparison.Ordinal)) + return Results.BadRequest(new { error = "unsupported_grant_type" }); + + if (string.IsNullOrWhiteSpace(request.DeviceCode)) + return Results.BadRequest(new { error = "invalid_request" }); + + var result = await authService.RedeemDeviceCodeAsync(request.DeviceCode, ct); + + return result.Status switch + { + DeviceRedeemStatus.Approved => Results.Ok(new + { + access_token = result.AccessToken, + refresh_token = result.RefreshToken, + token_type = "Bearer", + user = ToDto(result.User!) + }), + DeviceRedeemStatus.AuthorizationPending => Results.BadRequest(new { error = "authorization_pending" }), + DeviceRedeemStatus.AccessDenied => Results.BadRequest(new { error = "access_denied" }), + _ => Results.BadRequest(new { error = "expired_token" }) + }; + } + + private static async Task ApproveDevice( + [FromBody] DeviceApproveRequest request, + AuthService authService, + HttpContext httpContext, + CancellationToken ct) + { + var userId = httpContext.GetUserId(authService); + if (userId == null) + return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(request.UserCode)) + return Results.BadRequest(new { error = "invalid_request" }); + + var result = await authService.ApproveDeviceAsync(request.UserCode, userId.Value, ct); + return MapApproval(result); + } + + private static async Task DenyDevice( + [FromBody] DeviceApproveRequest request, + AuthService authService, + HttpContext httpContext, + CancellationToken ct) + { + var userId = httpContext.GetUserId(authService); + if (userId == null) + return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(request.UserCode)) + return Results.BadRequest(new { error = "invalid_request" }); + + var result = await authService.DenyDeviceAsync(request.UserCode, ct); + return MapApproval(result); + } + + private static IResult MapApproval(DeviceApprovalResult result) => result switch + { + DeviceApprovalResult.Ok => Results.Ok(new { status = "ok" }), + DeviceApprovalResult.NotFound => Results.BadRequest(new { error = "invalid_user_code" }), + DeviceApprovalResult.Expired => Results.BadRequest(new { error = "expired_user_code" }), + DeviceApprovalResult.AlreadyUsed => Results.BadRequest(new { error = "user_code_already_used" }), + _ => Results.BadRequest(new { error = "invalid_user_code" }) + }; + + private static UserDto ToDto(User user) => + new(user.Id, user.Email, user.Name, user.Picture, user.IsGuest, user.CreatedAt, user.NativeLanguage); + + // Bodies are JSON with snake_case keys (RFC 8628 §3.4). The frontend approve + // call sends { "user_code": "WDJB-MQXR" }; the CLI poll sends + // { "grant_type": "...", "device_code": "..." }. + public record DeviceTokenRequest( + [property: JsonPropertyName("grant_type")] string? GrantType, + [property: JsonPropertyName("device_code")] string? DeviceCode); + + public record DeviceApproveRequest( + [property: JsonPropertyName("user_code")] string? UserCode); +} diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index f42f56d6..b49953fe 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -230,6 +230,41 @@ opt.PermitLimit = 10; opt.QueueLimit = 0; }); + // Device Authorization Grant (RFC 8628, AI-050a) — all per-IP. + // device-code: CLI requests a device_code; one per CLI session, 5/min is ample. + options.AddPolicy("device-code", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions + { + Window = TimeSpan.FromMinutes(1), + PermitLimit = 5, + QueueLimit = 0, + }); + }); + // device-token: CLI polls the token endpoint ~every 5s (interval); 12/min covers + // honest polling with headroom and still caps scripted abuse. + options.AddPolicy("device-token", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions + { + Window = TimeSpan.FromMinutes(1), + PermitLimit = 12, + QueueLimit = 0, + }); + }); + // device-approve: authed consent action; one submit per CLI session. 10/min per IP. + options.AddPolicy("device-approve", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions + { + Window = TimeSpan.FromMinutes(1), + PermitLimit = 10, + QueueLimit = 0, + }); + }); // Per-IP partition — bot with one IP can't exhaust the limit for everyone. // 3 guest-creates per 5min per IP: covers legit shared-WiFi cases, blocks scripted abuse. // ForwardedHeaders runs before RateLimiter in the pipeline, so RemoteIpAddress is the real client. @@ -536,6 +571,7 @@ app.MapSeoEndpoints(); app.MapSsgEndpoints(); app.MapAuthEndpoints(); +app.MapDeviceAuthEndpoints(); app.MapProfileEndpoints(); app.MapUserDataEndpoints(); app.MapHighlightsEndpoints(); diff --git a/backend/src/Application/Auth/AuthService.cs b/backend/src/Application/Auth/AuthService.cs index fd1a1338..c474e0d5 100644 --- a/backend/src/Application/Auth/AuthService.cs +++ b/backend/src/Application/Auth/AuthService.cs @@ -17,17 +17,20 @@ public class AuthService private readonly JwtSettings _jwtSettings; private readonly GoogleSettings _googleSettings; private readonly AppleSettings? _appleSettings; + private readonly TimeProvider _clock; public AuthService( IAppDbContext db, IOptions jwtSettings, IOptions googleSettings, - IOptions? appleSettings = null) + IOptions? appleSettings = null, + TimeProvider? clock = null) { _db = db; _jwtSettings = jwtSettings.Value; _googleSettings = googleSettings.Value; _appleSettings = appleSettings?.Value; + _clock = clock ?? TimeProvider.System; } public async Task<(User user, string accessToken, string refreshToken)> TestLoginAsync( @@ -424,12 +427,170 @@ public async Task ResetPasswordAsync(string token, string newPassword, Can return true; } - private static string HashToken(string token) + // =========================================================================== + // OAuth 2.0 Device Authorization Grant (RFC 8628) — AI-050a + // Reuses the same GenerateAccessToken + CreateRefreshTokenAsync as the login + // path; uses the injected TimeProvider so expiry is testable with a fake clock. + // =========================================================================== + + /// + /// RFC 8628 §3.2 — create a device authorization. Returns the PLAINTEXT + /// device_code (returned to the CLI once, stored only hashed) + the short + /// user_code the user types into the consent page. + /// + public async Task CreateDeviceAuthorizationAsync(CancellationToken ct) + { + var deviceCode = DeviceCodes.GenerateSecureToken(); + var deviceCodeHash = DeviceCodes.HashToken(deviceCode); + var now = _clock.GetUtcNow(); + + var userCode = await GenerateUniqueUserCodeAsync(now, ct); + + const int expiresInSeconds = 600; // 10 min + const int intervalSeconds = 5; + + _db.DeviceAuthorizations.Add(new DeviceAuthorization + { + Id = Guid.NewGuid(), + DeviceCodeHash = deviceCodeHash, + UserCode = userCode, + Status = DeviceAuthorizationStatus.Pending, + ExpiresAt = now.AddSeconds(expiresInSeconds), + IntervalSeconds = intervalSeconds, + CreatedAt = now + }); + await _db.SaveChangesAsync(ct); + + return new DeviceCodeResult(deviceCode, userCode, expiresInSeconds, intervalSeconds); + } + + /// Approve a pending device authorization, binding it to the user. + public async Task ApproveDeviceAsync(string userCode, Guid userId, CancellationToken ct) + { + var normalized = DeviceCodes.NormalizeUserCode(userCode); + if (normalized.Length == 0) + return DeviceApprovalResult.NotFound; + + // Scope to the live pending row only. The UserCode index is FILTERED on + // status='pending' (not unique), so the same user_code legitimately recurs + // across history (old denied/expired/approved rows). Filtering on Pending + // makes those stale terminal rows invisible — we only ever act on the one + // still-live row. Expiry is checked below (lazy-expiry) so a genuinely + // past-deadline pending row still reports Expired rather than NotFound. + var row = await _db.DeviceAuthorizations.FirstOrDefaultAsync( + x => x.UserCode == normalized && x.Status == DeviceAuthorizationStatus.Pending, ct); + if (row == null) + return DeviceApprovalResult.NotFound; + + var now = _clock.GetUtcNow(); + if (row.ExpiresAt <= now) + { + row.Status = DeviceAuthorizationStatus.Expired; + await _db.SaveChangesAsync(ct); + return DeviceApprovalResult.Expired; + } + + row.Status = DeviceAuthorizationStatus.Approved; + row.UserId = userId; + await _db.SaveChangesAsync(ct); + return DeviceApprovalResult.Ok; + } + + /// Deny a pending device authorization (user rejected the CLI). + public async Task DenyDeviceAsync(string userCode, CancellationToken ct) + { + var normalized = DeviceCodes.NormalizeUserCode(userCode); + if (normalized.Length == 0) + return DeviceApprovalResult.NotFound; + + // Same scoping as ApproveDeviceAsync: act on the live pending row only so a + // stale terminal row sharing the same recurring user_code is never touched. + var row = await _db.DeviceAuthorizations.FirstOrDefaultAsync( + x => x.UserCode == normalized && x.Status == DeviceAuthorizationStatus.Pending, ct); + if (row == null) + return DeviceApprovalResult.NotFound; + + var now = _clock.GetUtcNow(); + if (row.ExpiresAt <= now) + { + row.Status = DeviceAuthorizationStatus.Expired; + await _db.SaveChangesAsync(ct); + return DeviceApprovalResult.Expired; + } + + row.Status = DeviceAuthorizationStatus.Denied; + await _db.SaveChangesAsync(ct); + return DeviceApprovalResult.Ok; + } + + /// + /// RFC 8628 §3.4/§3.5 — the CLI polls with its device_code. Returns either a + /// freshly-minted token pair (single-use) or one of the RFC error states. + /// + public async Task RedeemDeviceCodeAsync(string deviceCode, CancellationToken ct) + { + if (string.IsNullOrEmpty(deviceCode)) + return DeviceRedeemResult.ExpiredToken(); // unknown == don't distinguish + + var hash = DeviceCodes.HashToken(deviceCode); + var row = await _db.DeviceAuthorizations + .Include(x => x.User) + .FirstOrDefaultAsync(x => x.DeviceCodeHash == hash, ct); + + // Unknown device_code → expired_token (avoid enumeration; don't reveal "not found"). + if (row == null) + return DeviceRedeemResult.ExpiredToken(); + + var now = _clock.GetUtcNow(); + + // Lazily mark past-expiry pending rows expired. + if (row.Status == DeviceAuthorizationStatus.Pending && row.ExpiresAt <= now) + { + row.Status = DeviceAuthorizationStatus.Expired; + await _db.SaveChangesAsync(ct); + return DeviceRedeemResult.ExpiredToken(); + } + + switch (row.Status) + { + case DeviceAuthorizationStatus.Pending: + return DeviceRedeemResult.AuthorizationPending(); + case DeviceAuthorizationStatus.Denied: + return DeviceRedeemResult.AccessDenied(); + case DeviceAuthorizationStatus.Expired: + return DeviceRedeemResult.ExpiredToken(); + case DeviceAuthorizationStatus.Approved when row.User != null: + // Single-use: flip status off pending so a second redeem can't re-mint. + row.Status = DeviceAuthorizationStatus.Expired; + row.ConsumedAt = now; + var accessToken = GenerateAccessToken(row.User); + var refreshToken = await CreateRefreshTokenAsync(row.User.Id, ct); + return DeviceRedeemResult.Success(row.User, accessToken, refreshToken); + default: + // Approved-but-already-consumed (UserId set, status flipped) or any + // other terminal state → treat as expired (don't re-issue). + return DeviceRedeemResult.ExpiredToken(); + } + } + + private async Task GenerateUniqueUserCodeAsync(DateTimeOffset now, CancellationToken ct) { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); - return Convert.ToHexStringLower(bytes); + for (var attempt = 0; attempt < 8; attempt++) + { + var candidate = DeviceCodes.GenerateUserCode(); + // Collision only matters among still-live pending rows (the approve lookup). + var clash = await _db.DeviceAuthorizations.AnyAsync( + x => x.UserCode == candidate + && x.Status == DeviceAuthorizationStatus.Pending + && x.ExpiresAt > now, ct); + if (!clash) + return candidate; + } + throw new InvalidOperationException("Could not generate a unique device user_code."); } + private static string HashToken(string token) => DeviceCodes.HashToken(token); + public Guid? ValidateAccessToken(string accessToken) { var tokenHandler = new JwtSecurityTokenHandler(); diff --git a/backend/src/Application/Auth/DeviceAuthResults.cs b/backend/src/Application/Auth/DeviceAuthResults.cs new file mode 100644 index 00000000..571ea3d6 --- /dev/null +++ b/backend/src/Application/Auth/DeviceAuthResults.cs @@ -0,0 +1,46 @@ +using Domain.Entities; + +namespace Application.Auth; + +/// Result of . +/// Plaintext device_code (returned once, stored hashed). +/// Short user-facing code "XXXX-XXXX". +/// RFC 8628 expires_in. +/// RFC 8628 polling interval. +public record DeviceCodeResult(string DeviceCode, string UserCode, int ExpiresInSeconds, int IntervalSeconds); + +/// Outcome of approving/denying a device authorization by user_code. +public enum DeviceApprovalResult +{ + Ok, + NotFound, + Expired, + AlreadyUsed +} + +/// RFC 8628 §3.5 token-endpoint outcomes for a device_code poll. +public enum DeviceRedeemStatus +{ + Approved, + AuthorizationPending, + ExpiredToken, + AccessDenied +} + +/// +/// Discriminated result the device token endpoint maps to RFC 8628 errors. +/// On the token fields are populated. +/// +public sealed record DeviceRedeemResult( + DeviceRedeemStatus Status, + User? User = null, + string? AccessToken = null, + string? RefreshToken = null) +{ + public static DeviceRedeemResult Success(User user, string accessToken, string refreshToken) => + new(DeviceRedeemStatus.Approved, user, accessToken, refreshToken); + + public static DeviceRedeemResult AuthorizationPending() => new(DeviceRedeemStatus.AuthorizationPending); + public static DeviceRedeemResult ExpiredToken() => new(DeviceRedeemStatus.ExpiredToken); + public static DeviceRedeemResult AccessDenied() => new(DeviceRedeemStatus.AccessDenied); +} diff --git a/backend/src/Application/Auth/DeviceCodes.cs b/backend/src/Application/Auth/DeviceCodes.cs new file mode 100644 index 00000000..1497efec --- /dev/null +++ b/backend/src/Application/Auth/DeviceCodes.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Application.Auth; + +/// +/// Pure helpers for the device-grant flow (AI-050a): user_code generation + +/// normalization + token hashing. Extracted from so the +/// security-relevant logic is unit-testable without a DB or live server. +/// +public static class DeviceCodes +{ + // Crockford base32 minus ambiguous chars (no I, O, 0, 1). + public const string UserCodeAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789"; + + /// 8 chars from the unambiguous alphabet, grouped "XXXX-XXXX". + public static string GenerateUserCode() + { + Span chars = stackalloc char[8]; + for (var i = 0; i < chars.Length; i++) + chars[i] = UserCodeAlphabet[RandomNumberGenerator.GetInt32(UserCodeAlphabet.Length)]; + return $"{new string(chars[..4])}-{new string(chars[4..])}"; + } + + /// Uppercase + strip whitespace/dashes, then re-group as "XXXX-XXXX". + public static string NormalizeUserCode(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return ""; + var stripped = new string(input.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); + if (stripped.Length != 8) + return stripped; // != 8 → let the DB lookup miss (returns "" only for empty input) + return $"{stripped[..4]}-{stripped[4..]}"; + } + + /// SHA256-hex of a secret (device_code). Matches PasswordResetToken hashing. + public static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexStringLower(bytes); + } + + /// 64-byte cryptographically-random base64 secret (the device_code). + public static string GenerateSecureToken() + { + var bytes = new byte[64]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } +} diff --git a/backend/src/Application/Common/Interfaces/IAppDbContext.cs b/backend/src/Application/Common/Interfaces/IAppDbContext.cs index 019eafa7..42170d67 100644 --- a/backend/src/Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/Application/Common/Interfaces/IAppDbContext.cs @@ -51,6 +51,7 @@ public interface IAppDbContext DbSet WordClusters { get; } DbSet AutoPublishJobs { get; } DbSet PasswordResetTokens { get; } + DbSet DeviceAuthorizations { get; } DbSet BookQualityJobs { get; } DbSet SeoTemplates { get; } DbSet SeoBackfillJobs { get; } diff --git a/backend/src/Application/DependencyInjection.cs b/backend/src/Application/DependencyInjection.cs index 1f2a3d3d..d72f294e 100644 --- a/backend/src/Application/DependencyInjection.cs +++ b/backend/src/Application/DependencyInjection.cs @@ -12,6 +12,7 @@ using Application.SsgRebuild; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; namespace Application; @@ -20,6 +21,9 @@ public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { + // Testable clock for time-sensitive flows (device-grant expiry, AI-050a). + services.TryAddSingleton(TimeProvider.System); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Domain/Entities/DeviceAuthorization.cs b/backend/src/Domain/Entities/DeviceAuthorization.cs new file mode 100644 index 00000000..70cd8847 --- /dev/null +++ b/backend/src/Domain/Entities/DeviceAuthorization.cs @@ -0,0 +1,46 @@ +namespace Domain.Entities; + +/// +/// OAuth 2.0 Device Authorization Grant (RFC 8628) record. Backs the headless +/// MCP CLI device-grant flow: the CLI requests a device_code + user_code, the +/// user approves the user_code from an authenticated browser session, then the +/// CLI redeems the device_code for a per-user TextStack JWT. +/// +/// Security: the long device_code secret is stored SHA256-hashed (never plain), +/// mirroring . The user_code is short/low-entropy +/// — security rests on the authenticated approve step + 10-minute TTL + rate +/// limiting, not on user_code entropy. +/// +public class DeviceAuthorization +{ + public Guid Id { get; set; } + + /// SHA256-hex of the long device_code secret handed to the CLI. + public string DeviceCodeHash { get; set; } = ""; + + /// Short user-facing code, grouped "XXXX-XXXX" (Crockford base32, no I/O/0/1). + public string UserCode { get; set; } = ""; + + /// Null until the row is approved by an authenticated user. + public Guid? UserId { get; set; } + + /// pending | approved | denied | expired + public string Status { get; set; } = DeviceAuthorizationStatus.Pending; + + public DateTimeOffset ExpiresAt { get; set; } + public int IntervalSeconds { get; set; } = 5; + public DateTimeOffset CreatedAt { get; set; } + + /// Set when the device_code is redeemed for tokens (single-use marker). + public DateTimeOffset? ConsumedAt { get; set; } + + public User? User { get; set; } +} + +public static class DeviceAuthorizationStatus +{ + public const string Pending = "pending"; + public const string Approved = "approved"; + public const string Denied = "denied"; + public const string Expired = "expired"; +} diff --git a/backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.Designer.cs b/backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.Designer.cs new file mode 100644 index 00000000..c61c4456 --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.Designer.cs @@ -0,0 +1,4756 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; +using Pgvector; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260616160544_AddDeviceAuthorization")] + partial class AddDeviceAuthorization + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.AdminRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminUserId") + .HasColumnType("uuid") + .HasColumnName("admin_user_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.HasKey("Id") + .HasName("pk_admin_refresh_tokens"); + + b.HasIndex("AdminUserId") + .HasDatabaseName("ix_admin_refresh_tokens_admin_user_id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_admin_refresh_tokens_expires_at"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_admin_refresh_tokens_token"); + + b.ToTable("admin_refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AdminSettings", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_admin_settings"); + + b.ToTable("admin_settings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AdminUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_admin_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_admin_users_email"); + + b.ToTable("admin_users", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AgentRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Agent") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("agent"); + + b.Property("CostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("cost_usd"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Goal") + .IsRequired() + .HasColumnType("text") + .HasColumnName("goal"); + + b.Property("Iterations") + .HasColumnType("integer") + .HasColumnName("iterations"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("Output") + .HasColumnType("text") + .HasColumnName("output"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StepsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("steps_json"); + + b.Property("TokensIn") + .HasColumnType("integer") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("integer") + .HasColumnName("tokens_out"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_agent_run"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("ix_agent_run_user_id_created_at") + .HasFilter("user_id IS NOT NULL"); + + b.ToTable("agent_run", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("CanonicalOverride") + .HasColumnType("text") + .HasColumnName("canonical_override"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExternalLinksJson") + .HasColumnType("jsonb") + .HasColumnName("external_links_json"); + + b.Property("Indexable") + .HasColumnType("boolean") + .HasColumnName("indexable"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("PhotoPath") + .HasColumnType("text") + .HasColumnName("photo_path"); + + b.Property("SeoDescription") + .HasColumnType("text") + .HasColumnName("seo_description"); + + b.Property("SeoFaqsJson") + .HasColumnType("text") + .HasColumnName("seo_faqs_json"); + + b.Property("SeoRelevanceText") + .HasColumnType("text") + .HasColumnName("seo_relevance_text"); + + b.Property("SeoSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("seo_source"); + + b.Property("SeoThemesJson") + .HasColumnType("text") + .HasColumnName("seo_themes_json"); + + b.Property("SeoTitle") + .HasColumnType("text") + .HasColumnName("seo_title"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_authors_site_id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique() + .HasDatabaseName("ix_authors_site_id_slug"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AutoPublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("GeneratedAuthorSeo") + .HasColumnType("boolean") + .HasColumnName("generated_author_seo"); + + b.Property("GeneratedEditionSeo") + .HasColumnType("boolean") + .HasColumnName("generated_edition_seo"); + + b.Property("LogOutput") + .HasColumnType("text") + .HasColumnName("log_output"); + + b.Property("Priority") + .HasColumnType("boolean") + .HasColumnName("priority"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_auto_publish_jobs"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_auto_publish_jobs_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_auto_publish_jobs_site_id"); + + b.ToTable("auto_publish_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("bigint") + .HasColumnName("byte_size"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("OriginalPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_path"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("storage_path"); + + b.HasKey("Id") + .HasName("pk_book_assets"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_assets_edition_id"); + + b.HasIndex("EditionId", "OriginalPath") + .IsUnique() + .HasDatabaseName("ix_book_assets_edition_id_original_path"); + + b.ToTable("book_assets", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookCollection", b => + { + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("BookId") + .HasColumnType("uuid") + .HasColumnName("book_id"); + + b.Property("BookType") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("book_type"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.HasKey("CollectionId", "BookId", "BookType") + .HasName("pk_book_collections"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_book_collections_book_id"); + + b.ToTable("book_collections", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("original_file_name"); + + b.Property("Sha256") + .HasColumnType("text") + .HasColumnName("sha256"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("storage_path"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.HasKey("Id") + .HasName("pk_book_files"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_files_edition_id"); + + b.HasIndex("Sha256") + .HasDatabaseName("ix_book_files_sha256"); + + b.ToTable("book_files", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookQualityJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentChaptersCleaned") + .HasColumnType("integer") + .HasColumnName("content_chapters_cleaned"); + + b.Property("ContentChaptersRejected") + .HasColumnType("integer") + .HasColumnName("content_chapters_rejected"); + + b.Property("ContentChaptersSkipped") + .HasColumnType("integer") + .HasColumnName("content_chapters_skipped"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("IssuesFixed") + .HasColumnType("integer") + .HasColumnName("issues_fixed"); + + b.Property("IssuesFound") + .HasColumnType("integer") + .HasColumnName("issues_found"); + + b.Property("IssuesJson") + .HasColumnType("jsonb") + .HasColumnName("issues_json"); + + b.Property("LogOutput") + .HasColumnType("text") + .HasColumnName("log_output"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_book_quality_jobs"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_quality_jobs_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_book_quality_jobs_status"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_book_quality_jobs_user_book_id"); + + b.ToTable("book_quality_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Locator") + .IsRequired() + .HasColumnType("text") + .HasColumnName("locator"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_bookmarks"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_bookmarks_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_bookmarks_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_bookmarks_site_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .HasDatabaseName("ix_bookmarks_user_id_site_id_edition_id"); + + b.ToTable("bookmarks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterNumber") + .HasColumnType("integer") + .HasColumnName("chapter_number"); + + b.Property("ContentQualityScore") + .HasColumnType("integer") + .HasColumnName("content_quality_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Html") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html"); + + b.Property("OriginalChapterNumber") + .HasColumnType("integer") + .HasColumnName("original_chapter_number"); + + b.Property("PartNumber") + .HasColumnType("integer") + .HasColumnName("part_number"); + + b.Property("PlainText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("plain_text"); + + b.Property("SearchVector") + .IsRequired() + .HasColumnType("tsvector") + .HasColumnName("search_vector"); + + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TotalParts") + .HasColumnType("integer") + .HasColumnName("total_parts"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WordCount") + .HasColumnType("integer") + .HasColumnName("word_count"); + + b.HasKey("Id") + .HasName("pk_chapters"); + + b.HasIndex("SearchVector") + .HasDatabaseName("ix_chapters_search_vector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); + + b.HasIndex("EditionId", "ChapterNumber") + .IsUnique() + .HasDatabaseName("ix_chapters_edition_id_chapter_number"); + + b.HasIndex("EditionId", "Slug") + .HasDatabaseName("ix_chapters_edition_id_slug"); + + b.ToTable("chapters", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ChapterChunk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("ChapterOrd") + .HasColumnType("integer") + .HasColumnName("chapter_ord"); + + b.Property("CharEnd") + .HasColumnType("integer") + .HasColumnName("char_end"); + + b.Property("CharStart") + .HasColumnType("integer") + .HasColumnName("char_start"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("Ord") + .HasColumnType("integer") + .HasColumnName("ord"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("TokenCount") + .HasColumnType("integer") + .HasColumnName("token_count"); + + b.HasKey("Id") + .HasName("pk_chapter_chunk"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_chapter_chunk_chapter_id"); + + b.HasIndex("Embedding") + .HasDatabaseName("ix_chapter_chunk_embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("EditionId", "ChapterId", "Ord") + .HasDatabaseName("ix_chapter_chunk_edition_id_chapter_id_ord"); + + b.ToTable("chapter_chunk", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("default") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_collections_user_id"); + + b.HasIndex("UserId", "SortOrder") + .HasDatabaseName("ix_collections_user_id_sort_order"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.DeviceAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceCodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("device_code_hash"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IntervalSeconds") + .HasColumnType("integer") + .HasColumnName("interval_seconds"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("UserCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("user_code"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_device_authorizations"); + + b.HasIndex("DeviceCodeHash") + .IsUnique() + .HasDatabaseName("ix_device_authorizations_device_code_hash"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_device_authorizations_expires_at"); + + b.HasIndex("UserCode") + .HasDatabaseName("ix_device_authorizations_user_code") + .HasFilter("status = 'pending'"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_authorizations_user_id"); + + b.ToTable("device_authorizations", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Edition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CanonicalOverride") + .HasColumnType("text") + .HasColumnName("canonical_override"); + + b.Property("CoverPath") + .HasColumnType("text") + .HasColumnName("cover_path"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Indexable") + .HasColumnType("boolean") + .HasColumnName("indexable"); + + b.Property("IsPublicDomain") + .HasColumnType("boolean") + .HasColumnName("is_public_domain"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("SeoDescription") + .HasColumnType("text") + .HasColumnName("seo_description"); + + b.Property("SeoFaqsJson") + .HasColumnType("text") + .HasColumnName("seo_faqs_json"); + + b.Property("SeoRelevanceText") + .HasColumnType("text") + .HasColumnName("seo_relevance_text"); + + b.Property("SeoSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("seo_source"); + + b.Property("SeoThemesJson") + .HasColumnType("text") + .HasColumnName("seo_themes_json"); + + b.Property("SeoTitle") + .HasColumnType("text") + .HasColumnName("seo_title"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("SourceEditionId") + .HasColumnType("uuid") + .HasColumnName("source_edition_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TocJson") + .HasColumnType("jsonb") + .HasColumnName("toc_json"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WorkId") + .HasColumnType("uuid") + .HasColumnName("work_id"); + + b.HasKey("Id") + .HasName("pk_editions"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_editions_site_id"); + + b.HasIndex("SourceEditionId") + .HasDatabaseName("ix_editions_source_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_editions_status"); + + b.HasIndex("WorkId", "Language") + .IsUnique() + .HasDatabaseName("ix_editions_work_id_language"); + + b.HasIndex("SiteId", "Language", "Slug") + .IsUnique() + .HasDatabaseName("ix_editions_site_id_language_slug"); + + b.ToTable("editions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.EditionAuthor", b => + { + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("Order") + .HasColumnType("integer") + .HasColumnName("order"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.HasKey("EditionId", "AuthorId") + .HasName("pk_edition_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_edition_authors_author_id"); + + b.ToTable("edition_authors", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.EvalRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BreakdownJson") + .HasColumnType("jsonb") + .HasColumnName("breakdown_json"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Feature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("git_sha"); + + b.Property("JudgeModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("judge_model_id"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("N") + .HasColumnType("integer") + .HasColumnName("n"); + + b.Property("Score") + .HasColumnType("numeric(6,3)") + .HasColumnName("score"); + + b.HasKey("Id") + .HasName("pk_eval_runs"); + + b.HasIndex("Feature", "CreatedAt") + .HasDatabaseName("ix_eval_runs_feature_created_at"); + + b.ToTable("eval_runs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Indexable") + .HasColumnType("boolean") + .HasColumnName("indexable"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SeoDescription") + .HasColumnType("text") + .HasColumnName("seo_description"); + + b.Property("SeoSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("seo_source"); + + b.Property("SeoTitle") + .HasColumnType("text") + .HasColumnName("seo_title"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_genres"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_genres_site_id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique() + .HasDatabaseName("ix_genres_site_id_slug"); + + b.ToTable("genres", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Highlight", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchorJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("anchor_json"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_reviewed_at"); + + b.Property("NoteText") + .HasColumnType("text") + .HasColumnName("note_text"); + + b.Property("SelectedText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("selected_text"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserChapterId") + .HasColumnType("uuid") + .HasColumnName("user_chapter_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_highlights"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_highlights_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_highlights_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_highlights_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_highlights_user_book_id"); + + b.HasIndex("UserChapterId") + .HasDatabaseName("ix_highlights_user_chapter_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .HasDatabaseName("ix_highlights_user_id_site_id_edition_id") + .HasFilter("edition_id IS NOT NULL"); + + b.HasIndex("UserId", "SiteId", "UserBookId") + .HasDatabaseName("ix_highlights_user_id_site_id_user_book_id") + .HasFilter("user_book_id IS NOT NULL"); + + b.ToTable("highlights", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.IngestionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("BookFileId") + .HasColumnType("uuid") + .HasColumnName("book_file_id"); + + b.Property("Confidence") + .HasColumnType("double precision") + .HasColumnName("confidence"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("SourceEditionId") + .HasColumnType("uuid") + .HasColumnName("source_edition_id"); + + b.Property("SourceFormat") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source_format"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetLanguage") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("target_language"); + + b.Property("TextSource") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("text_source"); + + b.Property("UnitsCount") + .HasColumnType("integer") + .HasColumnName("units_count"); + + b.Property("WarningsJson") + .HasColumnType("jsonb") + .HasColumnName("warnings_json"); + + b.Property("WorkId") + .HasColumnType("uuid") + .HasColumnName("work_id"); + + b.HasKey("Id") + .HasName("pk_ingestion_jobs"); + + b.HasIndex("BookFileId") + .HasDatabaseName("ix_ingestion_jobs_book_file_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_ingestion_jobs_created_at"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_ingestion_jobs_edition_id"); + + b.HasIndex("SourceEditionId") + .HasDatabaseName("ix_ingestion_jobs_source_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_ingestion_jobs_status"); + + b.HasIndex("WorkId") + .HasDatabaseName("ix_ingestion_jobs_work_id"); + + b.ToTable("ingestion_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.LintResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterNumber") + .HasColumnType("integer") + .HasColumnName("chapter_number"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("LineNumber") + .HasColumnType("integer") + .HasColumnName("line_number"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("pk_lint_results"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_lint_results_edition_id"); + + b.ToTable("lint_results", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.LlmTrace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("cost_usd"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FeatureTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature_tag"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("MessagesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("messages_json"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("prompt_hash"); + + b.Property("ResponseText") + .HasColumnType("text") + .HasColumnName("response_text"); + + b.Property("SystemPrompt") + .HasColumnType("text") + .HasColumnName("system_prompt"); + + b.Property("TokensIn") + .HasColumnType("integer") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("integer") + .HasColumnName("tokens_out"); + + b.Property("ToolCallsJson") + .HasColumnType("jsonb") + .HasColumnName("tool_calls_json"); + + b.Property("TraceParentId") + .HasColumnType("uuid") + .HasColumnName("trace_parent_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_llm_traces"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_llm_traces_user_id") + .HasFilter("user_id IS NOT NULL"); + + b.HasIndex("FeatureTag", "CreatedAt") + .HasDatabaseName("ix_llm_traces_feature_tag_created_at"); + + b.ToTable("llm_traces", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("HighlightId") + .HasColumnType("uuid") + .HasColumnName("highlight_id"); + + b.Property("Locator") + .IsRequired() + .HasColumnType("text") + .HasColumnName("locator"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_notes"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_notes_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_notes_edition_id"); + + b.HasIndex("HighlightId") + .IsUnique() + .HasDatabaseName("ix_notes_highlight_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_notes_site_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .HasDatabaseName("ix_notes_user_id_site_id_edition_id"); + + b.ToTable("notes", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.Property("Used") + .HasColumnType("boolean") + .HasColumnName("used"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_password_reset_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_password_reset_tokens_user_id"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.PendingVocabularyWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Definition") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("definition"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("Priority") + .HasColumnType("double precision") + .HasColumnName("priority"); + + b.Property("Sentence") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sentence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("source"); + + b.Property("Translation") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("translation"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("ZipfRank") + .HasColumnType("integer") + .HasColumnName("zipf_rank"); + + b.Property("ZipfScore") + .HasColumnType("double precision") + .HasColumnName("zipf_score"); + + b.HasKey("Id") + .HasName("pk_pending_vocabulary_words"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_pending_vocabulary_words_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_pending_vocabulary_words_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_pending_vocabulary_words_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_pending_vocabulary_words_user_book_id"); + + b.HasIndex("UserId", "SiteId", "CreatedAt") + .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_created_at"); + + b.HasIndex("UserId", "SiteId", "Priority") + .IsDescending(false, false, true) + .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_priority"); + + b.HasIndex("UserId", "SiteId", "Word", "Language") + .IsUnique() + .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_word_language"); + + b.ToTable("pending_vocabulary_words", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.PodcastGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AudioPath") + .HasColumnType("text") + .HasColumnName("audio_path"); + + b.Property("CostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("cost_usd"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Lang") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("lang"); + + b.Property("ScriptJson") + .HasColumnType("jsonb") + .HasColumnName("script_json"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_podcast_generation_jobs"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_podcast_generation_jobs_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_podcast_generation_jobs_status"); + + b.ToTable("podcast_generation_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ReadingGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GoalType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("goal_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StreakMinMinutes") + .HasColumnType("integer") + .HasColumnName("streak_min_minutes"); + + b.Property("TargetValue") + .HasColumnType("integer") + .HasColumnName("target_value"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_reading_goals"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_reading_goals_site_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_reading_goals_user_id_site_id"); + + b.HasIndex("UserId", "SiteId", "GoalType") + .IsUnique() + .HasDatabaseName("ix_reading_goals_user_id_site_id_goal_type"); + + b.ToTable("reading_goals", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ReadingProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Locator") + .IsRequired() + .HasColumnType("text") + .HasColumnName("locator"); + + b.Property("MaxChapterNumber") + .HasColumnType("integer") + .HasColumnName("max_chapter_number"); + + b.Property("Percent") + .HasColumnType("double precision") + .HasColumnName("percent"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_reading_progresses"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_reading_progresses_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_reading_progresses_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_reading_progresses_site_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .IsUnique() + .HasDatabaseName("ix_reading_progresses_user_id_site_id_edition_id"); + + b.ToTable("reading_progresses", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ReadingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("EndPercent") + .HasColumnType("double precision") + .HasColumnName("end_percent"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StartPercent") + .HasColumnType("double precision") + .HasColumnName("start_percent"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WordsRead") + .HasColumnType("integer") + .HasColumnName("words_read"); + + b.HasKey("Id") + .HasName("pk_reading_sessions"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_reading_sessions_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_reading_sessions_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_reading_sessions_user_book_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_reading_sessions_user_id_site_id"); + + b.HasIndex("UserId", "StartedAt") + .HasDatabaseName("ix_reading_sessions_user_id_started_at"); + + b.HasIndex("UserId", "EditionId", "StartedAt") + .IsUnique() + .HasDatabaseName("ix_reading_sessions_user_id_edition_id_started_at") + .HasFilter("edition_id IS NOT NULL"); + + b.HasIndex("UserId", "UserBookId", "StartedAt") + .IsUnique() + .HasDatabaseName("ix_reading_sessions_user_id_user_book_id_started_at") + .HasFilter("user_book_id IS NOT NULL"); + + b.ToTable("reading_sessions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SeoBackfillJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AfterSnapshot") + .HasColumnType("jsonb") + .HasColumnName("after_snapshot"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BeforeSnapshot") + .HasColumnType("jsonb") + .HasColumnName("before_snapshot"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("GeneratedContent") + .HasColumnType("jsonb") + .HasColumnName("generated_content"); + + b.Property("InputSnapshot") + .HasColumnType("jsonb") + .HasColumnName("input_snapshot"); + + b.Property("RawOutputs") + .HasColumnType("jsonb") + .HasColumnName("raw_outputs"); + + b.Property("RenderedPrompts") + .HasColumnType("jsonb") + .HasColumnName("rendered_prompts"); + + b.Property("RequiresReview") + .HasColumnType("boolean") + .HasColumnName("requires_review"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.PrimitiveCollection("TargetFields") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("target_fields"); + + b.PrimitiveCollection("TemplateIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("template_ids"); + + b.PrimitiveCollection("TemplateVersions") + .IsRequired() + .HasColumnType("integer[]") + .HasColumnName("template_versions"); + + b.Property("TriggeredBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_seo_backfill_jobs"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("ix_seo_backfill_jobs_entity_type_entity_id"); + + b.HasIndex("Status", "CreatedAt") + .HasDatabaseName("ix_seo_backfill_jobs_status_created_at"); + + b.ToTable("seo_backfill_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SeoBackfillSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection("EntityTypeFilter") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("entity_type_filter"); + + b.Property("IntervalSeconds") + .HasColumnType("integer") + .HasColumnName("interval_seconds"); + + b.Property("JobsPerRun") + .HasColumnType("integer") + .HasColumnName("jobs_per_run"); + + b.PrimitiveCollection("LanguageFilter") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("language_filter"); + + b.Property("SsgRebuildBatchMinutes") + .HasColumnType("integer") + .HasColumnName("ssg_rebuild_batch_minutes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_seo_backfill_settings"); + + b.ToTable("seo_backfill_settings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SeoTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("FieldType") + .HasColumnType("integer") + .HasColumnName("field_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language_code"); + + b.Property("MaxTokens") + .HasColumnType("integer") + .HasColumnName("max_tokens"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OutputSchema") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("output_schema"); + + b.Property("PromptTemplate") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt_template"); + + b.Property("Temperature") + .HasColumnType("double precision") + .HasColumnName("temperature"); + + b.Property("TrustLevel") + .HasColumnType("integer") + .HasColumnName("trust_level"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_seo_templates"); + + b.HasIndex("EntityType", "FieldType", "LanguageCode", "IsActive") + .HasDatabaseName("ix_seo_templates_entity_type_field_type_language_code_is_active"); + + b.ToTable("seo_templates", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdsEnabled") + .HasColumnType("boolean") + .HasColumnName("ads_enabled"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultLanguage") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("default_language"); + + b.Property("FeaturesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features_json"); + + b.Property("IndexingEnabled") + .HasColumnType("boolean") + .HasColumnName("indexing_enabled"); + + b.Property("PrimaryDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("primary_domain"); + + b.Property("SitemapEnabled") + .HasColumnType("boolean") + .HasColumnName("sitemap_enabled"); + + b.Property("Theme") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("theme"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_sites"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_sites_code"); + + b.HasIndex("PrimaryDomain") + .IsUnique() + .HasDatabaseName("ix_sites_primary_domain"); + + b.ToTable("sites", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SiteDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.HasKey("Id") + .HasName("pk_site_domains"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_site_domains_domain"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_site_domains_site_id"); + + b.ToTable("site_domains", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorSlugsJson") + .HasColumnType("jsonb") + .HasColumnName("author_slugs_json"); + + b.Property("BookSlugsJson") + .HasColumnType("jsonb") + .HasColumnName("book_slugs_json"); + + b.Property("Concurrency") + .HasColumnType("integer") + .HasColumnName("concurrency"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FailedCount") + .HasColumnType("integer") + .HasColumnName("failed_count"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("GenreSlugsJson") + .HasColumnType("jsonb") + .HasColumnName("genre_slugs_json"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mode"); + + b.Property("RenderedCount") + .HasColumnType("integer") + .HasColumnName("rendered_count"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TimeoutMs") + .HasColumnType("integer") + .HasColumnName("timeout_ms"); + + b.Property("TotalRoutes") + .HasColumnType("integer") + .HasColumnName("total_routes"); + + b.HasKey("Id") + .HasName("pk_ssg_rebuild_jobs"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_ssg_rebuild_jobs_created_at"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_ssg_rebuild_jobs_site_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_ssg_rebuild_jobs_status"); + + b.ToTable("ssg_rebuild_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("RenderTimeMs") + .HasColumnType("integer") + .HasColumnName("render_time_ms"); + + b.Property("RenderedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rendered_at"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("route"); + + b.Property("RouteType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("route_type"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_ssg_rebuild_results"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_ssg_rebuild_results_job_id"); + + b.HasIndex("JobId", "Route") + .IsUnique() + .HasDatabaseName("ix_ssg_rebuild_results_job_id_route"); + + b.ToTable("ssg_rebuild_results", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.TextStackImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("identifier"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("imported_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.HasKey("Id") + .HasName("pk_text_stack_imports"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_text_stack_imports_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_text_stack_imports_site_id"); + + b.HasIndex("SiteId", "Identifier") + .IsUnique() + .HasDatabaseName("ix_text_stack_imports_site_id_identifier"); + + b.ToTable("text_stack_imports", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppleSubject") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("apple_subject"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("GoogleSubject") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("google_subject"); + + b.Property("IsGuest") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_guest"); + + b.Property("LastActiveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_active_at"); + + b.Property("Name") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("NativeLanguage") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("native_language"); + + b.Property("PasswordHash") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_hash"); + + b.Property("Picture") + .HasColumnType("text") + .HasColumnName("picture"); + + b.Property("StorageUsedBytes") + .HasColumnType("bigint") + .HasColumnName("storage_used_bytes"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("AppleSubject") + .IsUnique() + .HasDatabaseName("ix_users_apple_subject") + .HasFilter("apple_subject IS NOT NULL"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("GoogleSubject") + .IsUnique() + .HasDatabaseName("ix_users_google_subject") + .HasFilter("google_subject IS NOT NULL"); + + b.HasIndex("IsGuest", "LastActiveAt") + .HasDatabaseName("ix_users_guest_cleanup") + .HasFilter("is_guest = true"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("achievement_code"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("unlocked_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_achievements"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_user_achievements_site_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_user_achievements_user_id_site_id"); + + b.HasIndex("UserId", "SiteId", "AchievementCode") + .IsUnique() + .HasDatabaseName("ix_user_achievements_user_id_site_id_achievement_code"); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserBook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Author") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("author"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CoverPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("cover_path"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("Genre") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("genre"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("language"); + + b.Property("MetadataHistoryJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_history_json"); + + b.Property("ProgressChapterSlug") + .HasColumnType("text") + .HasColumnName("progress_chapter_slug"); + + b.Property("ProgressLocator") + .HasColumnType("text") + .HasColumnName("progress_locator"); + + b.Property("ProgressPercent") + .HasColumnType("double precision") + .HasColumnName("progress_percent"); + + b.Property("ProgressUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("progress_updated_at"); + + b.Property("PublishedYear") + .HasColumnType("integer") + .HasColumnName("published_year"); + + b.Property("SeoSource") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("auto") + .HasColumnName("seo_source"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.PrimitiveCollection("SuggestedTags") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("suggested_tags") + .HasDefaultValueSql("ARRAY[]::text[]"); + + b.Property("SuggestedTagsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suggested_tags_at"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("tags") + .HasDefaultValueSql("ARRAY[]::text[]"); + + b.Property("TakedownAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("takedown_at"); + + b.Property("TakedownReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("takedown_reason"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("TocJson") + .HasColumnType("jsonb") + .HasColumnName("toc_json"); + + b.Property("TotalWordCount") + .HasColumnType("integer") + .HasColumnName("total_word_count"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_books"); + + b.HasIndex("Status") + .HasDatabaseName("ix_user_books_status"); + + b.HasIndex("Tags") + .HasDatabaseName("ix_user_books_tags"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Tags"), "gin"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_books_user_id"); + + b.HasIndex("UserId", "Slug") + .IsUnique() + .HasDatabaseName("ix_user_books_user_id_slug"); + + b.ToTable("user_books", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserBookBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Locator") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("locator"); + + b.Property("Title") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_user_book_bookmarks"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_user_book_bookmarks_chapter_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_book_bookmarks_user_book_id"); + + b.ToTable("user_book_bookmarks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserBookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("storage_path"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_user_book_files"); + + b.HasIndex("Sha256") + .HasDatabaseName("ix_user_book_files_sha256"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_book_files_user_book_id"); + + b.ToTable("user_book_files", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserChapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterNumber") + .HasColumnType("integer") + .HasColumnName("chapter_number"); + + b.Property("ContentQualityScore") + .HasColumnType("integer") + .HasColumnName("content_quality_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Html") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html"); + + b.Property("PlainText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("plain_text"); + + b.Property("Slug") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("slug"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("WordCount") + .HasColumnType("integer") + .HasColumnName("word_count"); + + b.HasKey("Id") + .HasName("pk_user_chapters"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_chapters_user_book_id"); + + b.HasIndex("UserBookId", "ChapterNumber") + .IsUnique() + .HasDatabaseName("ix_user_chapters_user_book_id_chapter_number"); + + b.HasIndex("UserBookId", "Slug") + .IsUnique() + .HasDatabaseName("ix_user_chapters_user_book_id_slug"); + + b.ToTable("user_chapters", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserIngestionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("SourceFormat") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_format"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UnitsCount") + .HasColumnType("integer") + .HasColumnName("units_count"); + + b.Property("UserBookFileId") + .HasColumnType("uuid") + .HasColumnName("user_book_file_id"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_user_ingestion_jobs"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_user_ingestion_jobs_created_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_user_ingestion_jobs_status"); + + b.HasIndex("UserBookFileId") + .HasDatabaseName("ix_user_ingestion_jobs_user_book_file_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_ingestion_jobs_user_book_id"); + + b.ToTable("user_ingestion_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_libraries"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_user_libraries_edition_id"); + + b.HasIndex("UserId", "EditionId") + .IsUnique() + .HasDatabaseName("ix_user_libraries_user_id_edition_id"); + + b.ToTable("user_libraries", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_refresh_tokens"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_user_refresh_tokens_expires_at"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_user_refresh_tokens_token"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_refresh_tokens_user_id"); + + b.ToTable("user_refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserVocabularySettings", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("AutoRetireEnabled") + .HasColumnType("boolean") + .HasColumnName("auto_retire_enabled"); + + b.Property("ClusteringEnabled") + .HasColumnType("boolean") + .HasColumnName("clustering_enabled"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DailyNewCap") + .HasColumnType("integer") + .HasColumnName("daily_new_cap"); + + b.Property("FrequencyFilterEnabled") + .HasColumnType("boolean") + .HasColumnName("frequency_filter_enabled"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WeeklyReviewBudget") + .HasColumnType("integer") + .HasColumnName("weekly_review_budget"); + + b.HasKey("UserId", "SiteId") + .HasName("pk_user_vocabulary_settings"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_user_vocabulary_settings_site_id"); + + b.ToTable("user_vocabulary_settings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsCorrect") + .HasColumnType("boolean") + .HasColumnName("is_correct"); + + b.Property("ResponseTimeMs") + .HasColumnType("integer") + .HasColumnName("response_time_ms"); + + b.Property("ReviewMode") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("review_mode"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StageAfter") + .HasColumnType("integer") + .HasColumnName("stage_after"); + + b.Property("StageBefore") + .HasColumnType("integer") + .HasColumnName("stage_before"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VocabularyWordId") + .HasColumnType("uuid") + .HasColumnName("vocabulary_word_id"); + + b.HasKey("Id") + .HasName("pk_vocabulary_reviews"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_vocabulary_reviews_site_id"); + + b.HasIndex("VocabularyWordId") + .HasDatabaseName("ix_vocabulary_reviews_vocabulary_word_id"); + + b.HasIndex("UserId", "SiteId", "CreatedAt") + .HasDatabaseName("ix_vocabulary_reviews_user_id_site_id_created_at"); + + b.ToTable("vocabulary_reviews", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("ClusterId") + .HasColumnType("uuid") + .HasColumnName("cluster_id"); + + b.Property("ConsecutiveCorrect") + .HasColumnType("integer") + .HasColumnName("consecutive_correct"); + + b.Property("CorrectReviews") + .HasColumnType("integer") + .HasColumnName("correct_reviews"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Definition") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("definition"); + + b.Property("Distractors") + .HasColumnType("text") + .HasColumnName("distractors"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Explanation") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("explanation"); + + b.Property("Hint") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hint"); + + b.Property("IntervalDays") + .HasColumnType("double precision") + .HasColumnName("interval_days"); + + b.Property("IsRetired") + .HasColumnType("boolean") + .HasColumnName("is_retired"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_reviewed_at"); + + b.Property("NextReviewAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_review_at"); + + b.Property("Priority") + .HasColumnType("double precision") + .HasColumnName("priority"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("RetiredReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("retired_reason"); + + b.Property("Sentence") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sentence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("source"); + + b.Property("Stage") + .HasColumnType("integer") + .HasColumnName("stage"); + + b.Property("TotalReviews") + .HasColumnType("integer") + .HasColumnName("total_reviews"); + + b.Property("Translation") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("translation"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("ZipfRank") + .HasColumnType("integer") + .HasColumnName("zipf_rank"); + + b.Property("ZipfScore") + .HasColumnType("double precision") + .HasColumnName("zipf_score"); + + b.HasKey("Id") + .HasName("pk_vocabulary_words"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_vocabulary_words_chapter_id"); + + b.HasIndex("ClusterId") + .HasDatabaseName("ix_vocabulary_words_cluster_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_vocabulary_words_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_vocabulary_words_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_vocabulary_words_user_book_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_vocabulary_words_user_id_site_id"); + + b.HasIndex("UserId", "SiteId", "IsRetired", "NextReviewAt") + .HasDatabaseName("ix_vocabulary_words_user_id_site_id_is_retired_next_review_at"); + + b.HasIndex("UserId", "SiteId", "Word", "Language") + .IsUnique() + .HasDatabaseName("ix_vocabulary_words_user_id_site_id_word_language"); + + b.ToTable("vocabulary_words", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.WordCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("CohesionScore") + .HasColumnType("double precision") + .HasColumnName("cohesion_score"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("dismissed_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("IsConfirmed") + .HasColumnType("boolean") + .HasColumnName("is_confirmed"); + + b.Property("IsDismissed") + .HasColumnType("boolean") + .HasColumnName("is_dismissed"); + + b.Property("MemberCount") + .HasColumnType("integer") + .HasColumnName("member_count"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Theme") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("theme"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_word_clusters"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_word_clusters_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_word_clusters_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_word_clusters_user_book_id"); + + b.HasIndex("UserId", "SiteId", "IsDismissed", "CreatedAt") + .IsDescending(false, false, false, true) + .HasDatabaseName("ix_word_clusters_user_id_site_id_is_dismissed_created_at"); + + b.ToTable("word_clusters", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.WordFrequency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("Pos") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("pos"); + + b.Property("Rank") + .HasColumnType("integer") + .HasColumnName("rank"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("Zipf") + .HasColumnType("double precision") + .HasColumnName("zipf"); + + b.HasKey("Id") + .HasName("pk_word_frequencies"); + + b.HasIndex("Language", "Rank") + .HasDatabaseName("ix_word_frequencies_language_rank"); + + b.HasIndex("Language", "Word") + .IsUnique() + .HasDatabaseName("ix_word_frequencies_language_word"); + + b.ToTable("word_frequencies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.WordLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("FirstTappedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_tapped_at"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("LastTappedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_tapped_at"); + + b.Property("LastTranslation") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("last_translation"); + + b.Property("Sentence") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sentence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("TapCount") + .HasColumnType("integer") + .HasColumnName("tap_count"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("ZipfRank") + .HasColumnType("integer") + .HasColumnName("zipf_rank"); + + b.HasKey("Id") + .HasName("pk_word_lookups"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_word_lookups_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_word_lookups_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_word_lookups_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_word_lookups_user_book_id"); + + b.HasIndex("UserId", "SiteId", "LastTappedAt") + .IsDescending(false, false, true) + .HasDatabaseName("ix_word_lookups_user_id_site_id_last_tapped_at"); + + b.HasIndex("UserId", "SiteId", "Word", "Language") + .IsUnique() + .HasDatabaseName("ix_word_lookups_user_id_site_id_word_language"); + + b.ToTable("word_lookups", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Work", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_works"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_works_site_id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique() + .HasDatabaseName("ix_works_site_id_slug"); + + b.ToTable("works", (string)null); + }); + + modelBuilder.Entity("edition_genres", b => + { + b.Property("EditionsId") + .HasColumnType("uuid") + .HasColumnName("editions_id"); + + b.Property("GenresId") + .HasColumnType("uuid") + .HasColumnName("genres_id"); + + b.HasKey("EditionsId", "GenresId") + .HasName("pk_edition_genres"); + + b.HasIndex("GenresId") + .HasDatabaseName("ix_edition_genres_genres_id"); + + b.ToTable("edition_genres", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AdminRefreshToken", b => + { + b.HasOne("Domain.Entities.AdminUser", "AdminUser") + .WithMany("RefreshTokens") + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_admin_refresh_tokens_admin_users_admin_user_id"); + + b.Navigation("AdminUser"); + }); + + modelBuilder.Entity("Domain.Entities.AgentRun", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_agent_run_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Author", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_authors_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.AutoPublishJob", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auto_publish_jobs_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auto_publish_jobs_sites_site_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.BookAsset", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("Assets") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_assets_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.BookCollection", b => + { + b.HasOne("Domain.Entities.Collection", "Collection") + .WithMany("Books") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_collections_collections_collection_id"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Domain.Entities.BookFile", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("BookFiles") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_files_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.BookQualityJob", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_book_quality_jobs_editions_edition_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_book_quality_jobs_user_books_user_book_id"); + + b.Navigation("Edition"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.Bookmark", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany("Bookmarks") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bookmarks_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bookmarks_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bookmarks_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Bookmarks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bookmarks_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Chapter", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("Chapters") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chapters_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.ChapterChunk", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chapter_chunk_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chapter_chunk_editions_edition_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.Collection", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_collections_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.DeviceAuthorization", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_device_authorizations_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Edition", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_editions_sites_site_id"); + + b.HasOne("Domain.Entities.Edition", "SourceEdition") + .WithMany("TranslatedEditions") + .HasForeignKey("SourceEditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_editions_editions_source_edition_id"); + + b.HasOne("Domain.Entities.Work", "Work") + .WithMany("Editions") + .HasForeignKey("WorkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_editions_works_work_id"); + + b.Navigation("Site"); + + b.Navigation("SourceEdition"); + + b.Navigation("Work"); + }); + + modelBuilder.Entity("Domain.Entities.EditionAuthor", b => + { + b.HasOne("Domain.Entities.Author", "Author") + .WithMany("EditionAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_authors_authors_author_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("EditionAuthors") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_authors_editions_edition_id"); + + b.Navigation("Author"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.Genre", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_genres_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.Highlight", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_highlights_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_user_books_user_book_id"); + + b.HasOne("Domain.Entities.UserChapter", "UserChapter") + .WithMany() + .HasForeignKey("UserChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_user_chapters_user_chapter_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Highlights") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_highlights_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + + b.Navigation("UserChapter"); + }); + + modelBuilder.Entity("Domain.Entities.IngestionJob", b => + { + b.HasOne("Domain.Entities.BookFile", "BookFile") + .WithMany("IngestionJobs") + .HasForeignKey("BookFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_jobs_book_files_book_file_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("IngestionJobs") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_jobs_editions_edition_id"); + + b.HasOne("Domain.Entities.Edition", "SourceEdition") + .WithMany() + .HasForeignKey("SourceEditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_ingestion_jobs_editions_source_edition_id"); + + b.HasOne("Domain.Entities.Work", "Work") + .WithMany() + .HasForeignKey("WorkId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_ingestion_jobs_works_work_id"); + + b.Navigation("BookFile"); + + b.Navigation("Edition"); + + b.Navigation("SourceEdition"); + + b.Navigation("Work"); + }); + + modelBuilder.Entity("Domain.Entities.LintResult", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_lint_results_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.LlmTrace", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_llm_traces_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Note", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany("Notes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notes_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notes_editions_edition_id"); + + b.HasOne("Domain.Entities.Highlight", "Highlight") + .WithOne("Note") + .HasForeignKey("Domain.Entities.Note", "HighlightId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_notes_highlights_highlight_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_notes_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Notes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notes_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Highlight"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.PasswordResetToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_password_reset_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.PendingVocabularyWord", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_pending_vocabulary_words_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_pending_vocabulary_words_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_pending_vocabulary_words_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_pending_vocabulary_words_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pending_vocabulary_words_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.PodcastGenerationJob", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_podcast_generation_jobs_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.ReadingGoal", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_goals_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_goals_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.ReadingProgress", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany("ReadingProgresses") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_progresses_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_progresses_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_progresses_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("ReadingProgresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_progresses_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.ReadingSession", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reading_sessions_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_sessions_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reading_sessions_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_sessions_users_user_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.SiteDomain", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany("Domains") + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_site_domains_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildJob", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_ssg_rebuild_jobs_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildResult", b => + { + b.HasOne("Domain.Entities.SsgRebuildJob", "Job") + .WithMany("Results") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ssg_rebuild_results_ssg_rebuild_jobs_job_id"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Domain.Entities.TextStackImport", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_text_stack_imports_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_text_stack_imports_sites_site_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.UserAchievement", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_achievements_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_achievements_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserBook", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("UserBooks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_books_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserBookBookmark", b => + { + b.HasOne("Domain.Entities.UserChapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_book_bookmarks_user_chapters_chapter_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_book_bookmarks_user_books_user_book_id"); + + b.Navigation("Chapter"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.UserBookFile", b => + { + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany("BookFiles") + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_book_files_user_books_user_book_id"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.UserChapter", b => + { + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany("Chapters") + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_chapters_user_books_user_book_id"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.UserIngestionJob", b => + { + b.HasOne("Domain.Entities.UserBookFile", "UserBookFile") + .WithMany() + .HasForeignKey("UserBookFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_ingestion_jobs_user_book_files_user_book_file_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany("IngestionJobs") + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_ingestion_jobs_user_books_user_book_id"); + + b.Navigation("UserBook"); + + b.Navigation("UserBookFile"); + }); + + modelBuilder.Entity("Domain.Entities.UserLibrary", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_libraries_editions_edition_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("UserLibraries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_libraries_users_user_id"); + + b.Navigation("Edition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserRefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_refresh_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserVocabularySettings", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_vocabulary_settings_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_vocabulary_settings_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyReview", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_vocabulary_reviews_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_reviews_users_user_id"); + + b.HasOne("Domain.Entities.VocabularyWord", "VocabularyWord") + .WithMany("Reviews") + .HasForeignKey("VocabularyWordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_reviews_vocabulary_words_vocabulary_word_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("VocabularyWord"); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyWord", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_chapters_chapter_id"); + + b.HasOne("Domain.Entities.WordCluster", null) + .WithMany("Words") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_word_clusters_cluster_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_vocabulary_words_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_words_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.WordCluster", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_clusters_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_word_clusters_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_clusters_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_word_clusters_users_user_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.WordLookup", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_lookups_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_lookups_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_word_lookups_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_lookups_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_word_lookups_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.Work", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany("Works") + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_works_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("edition_genres", b => + { + b.HasOne("Domain.Entities.Edition", null) + .WithMany() + .HasForeignKey("EditionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_genres_editions_editions_id"); + + b.HasOne("Domain.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_genres_genres_genres_id"); + }); + + modelBuilder.Entity("Domain.Entities.AdminUser", b => + { + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Author", b => + { + b.Navigation("EditionAuthors"); + }); + + modelBuilder.Entity("Domain.Entities.BookFile", b => + { + b.Navigation("IngestionJobs"); + }); + + modelBuilder.Entity("Domain.Entities.Chapter", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Notes"); + + b.Navigation("ReadingProgresses"); + }); + + modelBuilder.Entity("Domain.Entities.Collection", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("Domain.Entities.Edition", b => + { + b.Navigation("Assets"); + + b.Navigation("BookFiles"); + + b.Navigation("Chapters"); + + b.Navigation("EditionAuthors"); + + b.Navigation("IngestionJobs"); + + b.Navigation("TranslatedEditions"); + }); + + modelBuilder.Entity("Domain.Entities.Highlight", b => + { + b.Navigation("Note"); + }); + + modelBuilder.Entity("Domain.Entities.Site", b => + { + b.Navigation("Domains"); + + b.Navigation("Works"); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildJob", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Highlights"); + + b.Navigation("Notes"); + + b.Navigation("ReadingProgresses"); + + b.Navigation("UserBooks"); + + b.Navigation("UserLibraries"); + }); + + modelBuilder.Entity("Domain.Entities.UserBook", b => + { + b.Navigation("BookFiles"); + + b.Navigation("Chapters"); + + b.Navigation("IngestionJobs"); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyWord", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("Domain.Entities.WordCluster", b => + { + b.Navigation("Words"); + }); + + modelBuilder.Entity("Domain.Entities.Work", b => + { + b.Navigation("Editions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.cs b/backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.cs new file mode 100644 index 00000000..c82f2d6a --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260616160544_AddDeviceAuthorization.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddDeviceAuthorization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "device_authorizations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + device_code_hash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + user_code = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + user_id = table.Column(type: "uuid", nullable: true), + status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + interval_seconds = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + consumed_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_device_authorizations", x => x.id); + table.ForeignKey( + name: "fk_device_authorizations_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "ix_device_authorizations_device_code_hash", + table: "device_authorizations", + column: "device_code_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_device_authorizations_expires_at", + table: "device_authorizations", + column: "expires_at"); + + migrationBuilder.CreateIndex( + name: "ix_device_authorizations_user_code", + table: "device_authorizations", + column: "user_code", + filter: "status = 'pending'"); + + migrationBuilder.CreateIndex( + name: "ix_device_authorizations_user_id", + table: "device_authorizations", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "device_authorizations"); + } + } +} diff --git a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 2b6218bd..2784f4f1 100644 --- a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -826,6 +826,71 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("collections", (string)null); }); + modelBuilder.Entity("Domain.Entities.DeviceAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceCodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("device_code_hash"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IntervalSeconds") + .HasColumnType("integer") + .HasColumnName("interval_seconds"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("UserCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("user_code"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_device_authorizations"); + + b.HasIndex("DeviceCodeHash") + .IsUnique() + .HasDatabaseName("ix_device_authorizations_device_code_hash"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_device_authorizations_expires_at"); + + b.HasIndex("UserCode") + .HasDatabaseName("ix_device_authorizations_user_code") + .HasFilter("status = 'pending'"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_authorizations_user_id"); + + b.ToTable("device_authorizations", (string)null); + }); + modelBuilder.Entity("Domain.Entities.Edition", b => { b.Property("Id") @@ -3782,6 +3847,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Domain.Entities.DeviceAuthorization", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_device_authorizations_users_user_id"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Domain.Entities.Edition", b => { b.HasOne("Domain.Entities.Site", "Site") diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.User.cs b/backend/src/Infrastructure/Persistence/AppDbContext.User.cs index 341d2fd0..116fd143 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.User.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.User.cs @@ -69,5 +69,19 @@ private static void ConfigureUser(ModelBuilder modelBuilder) e.Property(x => x.TokenHash).HasMaxLength(128); e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); }); + + // DeviceAuthorization (RFC 8628 device-grant). device_code stored hashed. + modelBuilder.Entity(e => + { + e.Property(x => x.DeviceCodeHash).HasMaxLength(128); + e.Property(x => x.UserCode).HasMaxLength(16); + e.Property(x => x.Status).HasMaxLength(16); + e.HasIndex(x => x.DeviceCodeHash).IsUnique(); + // Lookup-by-user_code only matters for pending rows (approve step). + e.HasIndex(x => x.UserCode).HasFilter("status = 'pending'"); + e.HasIndex(x => x.ExpiresAt); + // User stays null until approved; SetNull keeps the audit row if the user is deleted. + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.SetNull); + }); } } diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.cs b/backend/src/Infrastructure/Persistence/AppDbContext.cs index fe932630..ea213eff 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.cs @@ -72,6 +72,7 @@ public Task BeginTransactionAsync(CancellationToken ct = public DbSet WordClusters => Set(); public DbSet AutoPublishJobs => Set(); public DbSet PasswordResetTokens => Set(); + public DbSet DeviceAuthorizations => Set(); public DbSet BookQualityJobs => Set(); public DbSet SeoTemplates => Set(); public DbSet SeoBackfillJobs => Set(); diff --git a/tests/TextStack.AiEvals/CapturingDb.cs b/tests/TextStack.AiEvals/CapturingDb.cs index d3d23522..df4ca52e 100644 --- a/tests/TextStack.AiEvals/CapturingDb.cs +++ b/tests/TextStack.AiEvals/CapturingDb.cs @@ -87,6 +87,7 @@ public override EntityEntry Add(EvalRun entity) public DbSet WordClusters => throw new NotSupportedException(); public DbSet AutoPublishJobs => throw new NotSupportedException(); public DbSet PasswordResetTokens => throw new NotSupportedException(); + public DbSet DeviceAuthorizations => throw new NotSupportedException(); public DbSet BookQualityJobs => throw new NotSupportedException(); public DbSet SeoTemplates => throw new NotSupportedException(); public DbSet SeoBackfillJobs => throw new NotSupportedException(); diff --git a/tests/TextStack.IntegrationTests/DeviceAuthEndpointTests.cs b/tests/TextStack.IntegrationTests/DeviceAuthEndpointTests.cs new file mode 100644 index 00000000..f06fd0aa --- /dev/null +++ b/tests/TextStack.IntegrationTests/DeviceAuthEndpointTests.cs @@ -0,0 +1,308 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Npgsql; + +namespace TextStack.IntegrationTests; + +/// +/// Integration tests for the OAuth 2.0 Device Authorization Grant (RFC 8628, AI-050a). +/// Requires: docker compose up (API at localhost:8080), ENABLE_TEST_AUTH=true. +/// +/// The DB-poking tests (expired seed, hashed-storage assertion) need a host-reachable +/// Postgres via TEST_DB_CONNECTION; they SKIP when it's unset so the HTTP-only suite +/// still runs anywhere. +/// +public class DeviceAuthEndpointTests : IClassFixture +{ + private const string GrantType = "urn:ietf:params:oauth:grant-type:device_code"; + + private readonly AuthenticatedApiFixture _fixture; + + public DeviceAuthEndpointTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + } + + private async Task RequestCodeAsync(CancellationToken ct) + { + var req = _fixture.CreateRequest(HttpMethod.Post, "/auth/device/code"); + var resp = await _fixture.Client.SendAsync(req, ct); + Assert.SkipWhen(IntegrationSkip.Unavailable(resp), "device/code endpoint unavailable"); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadFromJsonAsync(ct); + } + + private async Task PollAsync(string deviceCode, CancellationToken ct) + { + var req = _fixture.CreateRequest(HttpMethod.Post, "/auth/device/token"); + req.Content = JsonContent.Create(new { grant_type = GrantType, device_code = deviceCode }); + return await _fixture.Client.SendAsync(req, ct); + } + + [Fact] + public async Task RequestDeviceCode_ReturnsRfcFields() + { + var ct = TestContext.Current.CancellationToken; + var body = await RequestCodeAsync(ct); + + // RFC 8628 §3.2 snake_case fields. + Assert.False(string.IsNullOrEmpty(body.GetProperty("device_code").GetString())); + var userCode = body.GetProperty("user_code").GetString()!; + Assert.Matches("^[A-Z2-9]{4}-[A-Z2-9]{4}$", userCode); + + var verificationUri = body.GetProperty("verification_uri").GetString()!; + Assert.EndsWith("/device", verificationUri); + + var complete = body.GetProperty("verification_uri_complete").GetString()!; + Assert.Contains("code=", complete); + Assert.Contains(Uri.EscapeDataString(userCode), complete); + + Assert.Equal(600, body.GetProperty("expires_in").GetInt32()); + Assert.Equal(5, body.GetProperty("interval").GetInt32()); + } + + [Fact] + public async Task PollToken_BeforeApproval_ReturnsAuthorizationPending() + { + var ct = TestContext.Current.CancellationToken; + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + + var resp = await PollAsync(deviceCode, ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var err = await resp.Content.ReadFromJsonAsync(ct); + Assert.Equal("authorization_pending", err.GetProperty("error").GetString()); + } + + [Fact] + public async Task Approve_RequiresAuth_401WhenAnonymous() + { + var ct = TestContext.Current.CancellationToken; + + // Anonymous request (no Cookie header). + var req = new HttpRequestMessage(HttpMethod.Post, "/auth/device/approve"); + req.Headers.Host = AuthenticatedApiFixture.TestHost; + req.Content = JsonContent.Create(new { user_code = "WDJB-MQXR" }); + + var resp = await _fixture.Client.SendAsync(req, ct); + Assert.SkipWhen(IntegrationSkip.Unavailable(resp), "device/approve endpoint unavailable"); + + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task PollToken_AfterApproval_ReturnsAccessAndRefreshToken() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "test-login unavailable"); + var ct = TestContext.Current.CancellationToken; + + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + var userCode = body.GetProperty("user_code").GetString()!; + + // Authenticated approve. + var approveReq = _fixture.CreateRequest(HttpMethod.Post, "/auth/device/approve"); + approveReq.Content = JsonContent.Create(new { user_code = userCode }); + var approveResp = await _fixture.Client.SendAsync(approveReq, ct); + Assert.SkipWhen(IntegrationSkip.Unavailable(approveResp), "device/approve unavailable"); + Assert.Equal(HttpStatusCode.OK, approveResp.StatusCode); + + // CLI poll → tokens. + var resp = await PollAsync(deviceCode, ct); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var tokens = await resp.Content.ReadFromJsonAsync(ct); + Assert.False(string.IsNullOrEmpty(tokens.GetProperty("access_token").GetString())); + Assert.False(string.IsNullOrEmpty(tokens.GetProperty("refresh_token").GetString())); + Assert.Equal("Bearer", tokens.GetProperty("token_type").GetString()); + Assert.True(tokens.TryGetProperty("user", out var user)); + Assert.False(string.IsNullOrEmpty(user.GetProperty("email").GetString())); + } + + [Fact] + public async Task Approve_BindsUserId_AndSingleUse() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "test-login unavailable"); + var ct = TestContext.Current.CancellationToken; + + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + var userCode = body.GetProperty("user_code").GetString()!; + + var approveReq = _fixture.CreateRequest(HttpMethod.Post, "/auth/device/approve"); + approveReq.Content = JsonContent.Create(new { user_code = userCode }); + var approveResp = await _fixture.Client.SendAsync(approveReq, ct); + Assert.SkipWhen(IntegrationSkip.Unavailable(approveResp), "device/approve unavailable"); + Assert.Equal(HttpStatusCode.OK, approveResp.StatusCode); + + // First redeem consumes the code (single-use). + var first = await PollAsync(deviceCode, ct); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + // Second redeem must NOT re-mint — single-use, now expired_token. + var second = await PollAsync(deviceCode, ct); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + var err = await second.Content.ReadFromJsonAsync(ct); + Assert.Equal("expired_token", err.GetProperty("error").GetString()); + } + + [Fact] + public async Task PollToken_UnknownDeviceCode_ReturnsExpiredToken() + { + var ct = TestContext.Current.CancellationToken; + // Ensure endpoint reachable first. + await RequestCodeAsync(ct); + + var resp = await PollAsync("this-is-not-a-real-device-code", ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var err = await resp.Content.ReadFromJsonAsync(ct); + Assert.Equal("expired_token", err.GetProperty("error").GetString()); + } + + // ---- DB-backed tests (need host-reachable Postgres) ---- + + private static string? DbConn => Environment.GetEnvironmentVariable("TEST_DB_CONNECTION"); + + [Fact] + public async Task DeviceCode_StoredHashed() + { + Assert.SkipWhen(DbConn is null, "TEST_DB_CONNECTION not set"); + var ct = TestContext.Current.CancellationToken; + + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + var userCode = body.GetProperty("user_code").GetString()!; + + await using var conn = new NpgsqlConnection(DbConn); + await conn.OpenAsync(ct); + await using var cmd = new NpgsqlCommand( + "SELECT device_code_hash FROM device_authorizations WHERE user_code = @uc", conn); + cmd.Parameters.AddWithValue("uc", userCode); + var storedHash = (string?)await cmd.ExecuteScalarAsync(ct); + + Assert.NotNull(storedHash); + // Persisted value is the hash, never the plaintext device_code. + Assert.NotEqual(deviceCode, storedHash); + Assert.Equal(64, storedHash!.Length); // SHA256 hex + } + + [Fact] + public async Task Approve_FreshPendingRow_IgnoresStaleSameCodeTerminalRow() + { + // P2#1 (AI-050a): the user_code index is FILTERED on status='pending', so the + // same 8-char code legitimately recurs across history. Approve/deny must scope + // to the live pending row — a stale terminal row of the same code must be + // invisible, otherwise FirstOrDefault could return it and wrongly yield + // AlreadyUsed/Expired for a genuinely-approvable CLI flow. + Assert.SkipWhen(DbConn is null, "TEST_DB_CONNECTION not set"); + Assert.SkipUnless(_fixture.IsAuthenticated, "test-login unavailable"); + var ct = TestContext.Current.CancellationToken; + + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + var userCode = body.GetProperty("user_code").GetString()!; + + // Seed an OLDER terminal (denied) row sharing the same user_code, created + // before the live one — ordering by created_at would surface it first. + await using (var conn = new NpgsqlConnection(DbConn)) + { + await conn.OpenAsync(ct); + await using var cmd = new NpgsqlCommand( + """ + INSERT INTO device_authorizations + (id, device_code_hash, user_code, status, expires_at, interval_seconds, created_at) + VALUES + (@id, @hash, @uc, 'denied', now() - interval '1 hour', 5, now() - interval '2 hour') + """, conn); + cmd.Parameters.AddWithValue("id", Guid.NewGuid()); + cmd.Parameters.AddWithValue("hash", new string('a', 64)); // distinct device_code_hash + cmd.Parameters.AddWithValue("uc", userCode); + await cmd.ExecuteNonQueryAsync(ct); + } + + // Approve must bind the LIVE pending row, not the stale denied one. + var approveReq = _fixture.CreateRequest(HttpMethod.Post, "/auth/device/approve"); + approveReq.Content = JsonContent.Create(new { user_code = userCode }); + var approveResp = await _fixture.Client.SendAsync(approveReq, ct); + Assert.SkipWhen(IntegrationSkip.Unavailable(approveResp), "device/approve unavailable"); + Assert.Equal(HttpStatusCode.OK, approveResp.StatusCode); + + // And the live row's device_code now mints tokens — proving the right row moved. + var poll = await PollAsync(deviceCode, ct); + Assert.Equal(HttpStatusCode.OK, poll.StatusCode); + var tokens = await poll.Content.ReadFromJsonAsync(ct); + Assert.False(string.IsNullOrEmpty(tokens.GetProperty("access_token").GetString())); + } + + [Fact] + public async Task Deny_FreshPendingRow_ThenPollReturnsAccessDenied() + { + // Companion to the approve scoping test: deny must also hit the live pending + // row so the CLI poll transitions to access_denied (not authorization_pending). + Assert.SkipWhen(DbConn is null, "TEST_DB_CONNECTION not set"); + Assert.SkipUnless(_fixture.IsAuthenticated, "test-login unavailable"); + var ct = TestContext.Current.CancellationToken; + + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + var userCode = body.GetProperty("user_code").GetString()!; + + // Older approved-then-consumed (expired) terminal row, same code. + await using (var conn = new NpgsqlConnection(DbConn)) + { + await conn.OpenAsync(ct); + await using var cmd = new NpgsqlCommand( + """ + INSERT INTO device_authorizations + (id, device_code_hash, user_code, status, expires_at, interval_seconds, created_at) + VALUES + (@id, @hash, @uc, 'expired', now() - interval '1 hour', 5, now() - interval '2 hour') + """, conn); + cmd.Parameters.AddWithValue("id", Guid.NewGuid()); + cmd.Parameters.AddWithValue("hash", new string('b', 64)); + cmd.Parameters.AddWithValue("uc", userCode); + await cmd.ExecuteNonQueryAsync(ct); + } + + var denyReq = _fixture.CreateRequest(HttpMethod.Post, "/auth/device/deny"); + denyReq.Content = JsonContent.Create(new { user_code = userCode }); + var denyResp = await _fixture.Client.SendAsync(denyReq, ct); + Assert.SkipWhen(IntegrationSkip.Unavailable(denyResp), "device/deny unavailable"); + Assert.Equal(HttpStatusCode.OK, denyResp.StatusCode); + + var poll = await PollAsync(deviceCode, ct); + Assert.Equal(HttpStatusCode.BadRequest, poll.StatusCode); + var err = await poll.Content.ReadFromJsonAsync(ct); + Assert.Equal("access_denied", err.GetProperty("error").GetString()); + } + + [Fact] + public async Task PollToken_ExpiredCode_ReturnsExpiredToken() + { + Assert.SkipWhen(DbConn is null, "TEST_DB_CONNECTION not set"); + var ct = TestContext.Current.CancellationToken; + + var body = await RequestCodeAsync(ct); + var deviceCode = body.GetProperty("device_code").GetString()!; + var userCode = body.GetProperty("user_code").GetString()!; + + // Force expiry in the past via direct DB update (faster + deterministic vs sleeping 10 min). + await using (var conn = new NpgsqlConnection(DbConn)) + { + await conn.OpenAsync(ct); + await using var cmd = new NpgsqlCommand( + "UPDATE device_authorizations SET expires_at = now() - interval '1 hour' WHERE user_code = @uc", conn); + cmd.Parameters.AddWithValue("uc", userCode); + await cmd.ExecuteNonQueryAsync(ct); + } + + var resp = await PollAsync(deviceCode, ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var err = await resp.Content.ReadFromJsonAsync(ct); + Assert.Equal("expired_token", err.GetProperty("error").GetString()); + } +} diff --git a/tests/TextStack.UnitTests/DeviceCodesTests.cs b/tests/TextStack.UnitTests/DeviceCodesTests.cs new file mode 100644 index 00000000..38db3744 --- /dev/null +++ b/tests/TextStack.UnitTests/DeviceCodesTests.cs @@ -0,0 +1,145 @@ +using Application.Auth; + +namespace TextStack.UnitTests; + +/// +/// Pure-logic tests for the device-grant (RFC 8628, AI-050a) helpers: +/// user_code shape/alphabet, normalization, and SHA256 hashing. +/// +public class DeviceCodesTests +{ + [Fact] + public void GenerateUserCode_HasGroupedShape_XXXXdashXXXX() + { + var code = DeviceCodes.GenerateUserCode(); + + Assert.Equal(9, code.Length); + Assert.Equal('-', code[4]); + Assert.Matches("^[A-Z2-9]{4}-[A-Z2-9]{4}$", code); + } + + [Fact] + public void GenerateUserCode_UsesUnambiguousAlphabetOnly_NoIO01() + { + // 200 samples: never emit an ambiguous char. + for (var i = 0; i < 200; i++) + { + var code = DeviceCodes.GenerateUserCode().Replace("-", ""); + foreach (var c in code) + { + Assert.DoesNotContain(c, "IO01"); + Assert.Contains(c, DeviceCodes.UserCodeAlphabet); + } + } + } + + [Fact] + public void GenerateUserCode_ProducesVariedValues_NotConstant() + { + var set = new HashSet(); + for (var i = 0; i < 50; i++) + set.Add(DeviceCodes.GenerateUserCode()); + + Assert.True(set.Count > 40, "user_code should be high-entropy across samples"); + } + + [Theory] + [InlineData("wdjb-mqxr", "WDJB-MQXR")] + [InlineData("WDJBMQXR", "WDJB-MQXR")] + [InlineData(" wdjb mqxr ", "WDJB-MQXR")] + [InlineData("wdjb-MQXR", "WDJB-MQXR")] + public void NormalizeUserCode_UppercasesAndRegroups(string input, string expected) + { + Assert.Equal(expected, DeviceCodes.NormalizeUserCode(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NormalizeUserCode_EmptyOrWhitespace_ReturnsEmpty(string? input) + { + Assert.Equal("", DeviceCodes.NormalizeUserCode(input)); + } + + [Fact] + public void NormalizeUserCode_WrongLength_ReturnsStrippedUpper_LookupWillMiss() + { + // 5 stripped chars != 8 → not regrouped; DB lookup misses (returns NotFound). + Assert.Equal("ABCDE", DeviceCodes.NormalizeUserCode("ab-cde")); + } + + [Fact] + public void NormalizeUserCode_GeneratedCode_NormalizesToItself() + { + // The approve lookup compares NormalizeUserCode(input) == row.UserCode, and + // rows store exactly GenerateUserCode()'s output. If a freshly-generated code + // did not normalize to itself, every approve would miss → flow broken. + for (var i = 0; i < 100; i++) + { + var code = DeviceCodes.GenerateUserCode(); + Assert.Equal(code, DeviceCodes.NormalizeUserCode(code)); + } + } + + [Theory] + [InlineData("wd jb-mq xr", "WDJB-MQXR")] // interior spaces stripped + [InlineData("WDJB--MQXR", "WDJB-MQXR")] // double dash stripped + [InlineData("wdjbmqxr\n", "WDJB-MQXR")] // trailing newline stripped + public void NormalizeUserCode_StripsNonAlnum_AndRegroups(string input, string expected) + { + // Mirrors the frontend's lenient input handling; the canonical form must be + // exactly what GenerateUserCode produces so the DB equality lookup hits. + Assert.Equal(expected, DeviceCodes.NormalizeUserCode(input)); + } + + [Fact] + public void NormalizeUserCode_LongInput_DoesNotRegroup_LookupMisses() + { + // 9 alnum chars != 8 → returned stripped/upper but NOT regrouped, so it can + // never equal a stored "XXXX-XXXX" row → lookup misses (no oracle, no crash). + Assert.Equal("ABCDEFGHJ", DeviceCodes.NormalizeUserCode("abcd-efghj")); + } + + [Fact] + public void HashToken_DistinctTokens_ProduceDistinctHashes() + { + // No accidental collision/truncation: two different device_codes must hash + // to different values (the unique index relies on this). + var a = DeviceCodes.HashToken(DeviceCodes.GenerateSecureToken()); + var b = DeviceCodes.HashToken(DeviceCodes.GenerateSecureToken()); + Assert.NotEqual(a, b); + } + + [Fact] + public void HashToken_IsDeterministicSha256Hex() + { + var hash = DeviceCodes.HashToken("hello"); + + // SHA256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash); + Assert.Equal(64, hash.Length); + } + + [Fact] + public void HashToken_DiffersFromPlaintext_AndIsStable() + { + var token = DeviceCodes.GenerateSecureToken(); + + var hash = DeviceCodes.HashToken(token); + + Assert.NotEqual(token, hash); + Assert.Equal(hash, DeviceCodes.HashToken(token)); // stable + } + + [Fact] + public void GenerateSecureToken_IsLongRandomBase64_AndUnique() + { + var a = DeviceCodes.GenerateSecureToken(); + var b = DeviceCodes.GenerateSecureToken(); + + Assert.NotEqual(a, b); + // 64 random bytes → 88-char base64. + Assert.Equal(88, a.Length); + } +}