From 9a7eecb828e9b392b9e0cd2b12c3243f51a52eef Mon Sep 17 00:00:00 2001 From: Jarel Date: Tue, 21 Jul 2026 17:45:49 +1000 Subject: [PATCH 01/15] fix(deps): clear high-severity audit findings --- package-lock.json | 32 ++++++++++++++++---------------- package.json | 5 +++-- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee11eaa..15f216b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@vitejs/plugin-react": "^5.1.1", "concurrently": "^10.0.3", "eslint": "^9.39.1", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "rollup": "npm:@rollup/wasm-node@^4.60.4", "tsx": "^4.20.6", "typescript": "^5.9.3", @@ -1973,9 +1973,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2384,9 +2384,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3303,9 +3303,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3462,9 +3462,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4529,9 +4529,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index cac3e6b..aaff372 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "overrides": { "@babel/core": "7.29.7", "esbuild": "0.28.1", - "js-yaml": "4.2.0" + "js-yaml": "4.3.0", + "shell-quote": "1.10.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -76,7 +77,7 @@ "@vitejs/plugin-react": "^5.1.1", "concurrently": "^10.0.3", "eslint": "^9.39.1", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "rollup": "npm:@rollup/wasm-node@^4.60.4", "tsx": "^4.20.6", "typescript": "^5.9.3", From e1a867e9d19175ae810ca13a2f7be31c18f9966d Mon Sep 17 00:00:00 2001 From: Jarel Date: Tue, 21 Jul 2026 17:47:20 +1000 Subject: [PATCH 02/15] fix(server): keep auth available under write contention --- src/server/auth/userRepository.ts | 7 ++-- src/server/config.ts | 20 +++++++--- tests/config.test.ts | 61 +++++++++++++++++++++++++++++++ tests/userRepository.test.ts | 35 ++++++++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 tests/config.test.ts diff --git a/src/server/auth/userRepository.ts b/src/server/auth/userRepository.ts index 6c039a3..e2107fa 100644 --- a/src/server/auth/userRepository.ts +++ b/src/server/auth/userRepository.ts @@ -129,6 +129,7 @@ export class UserRepository { } createSession(userId: string) { + this.purgeExpiredSessions(); const token = crypto.randomBytes(32).toString("base64url"); const now = new Date(); const expiresAt = new Date(now.getTime() + sessionTtlMs).toISOString(); @@ -140,7 +141,6 @@ export class UserRepository { findSessionUser(token: string | undefined) { if (!token) return undefined; - this.purgeExpiredSessions(); const row = this.db .prepare( `SELECT app_users.* @@ -152,9 +152,7 @@ export class UserRepository { LIMIT 1` ) .get(tokenHash(token), new Date().toISOString()) as UserRow | undefined; - if (!row) return undefined; - this.db.prepare("UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?").run(new Date().toISOString(), tokenHash(token)); - return inflateUser(row); + return row ? inflateUser(row) : undefined; } revokeSession(token: string | undefined) { @@ -177,6 +175,7 @@ export class UserRepository { private purgeExpiredSessions() { this.db.prepare("DELETE FROM user_sessions WHERE expires_at <= ?").run(new Date().toISOString()); } + } function inflateUser(row: UserRow): AuthUser { diff --git a/src/server/config.ts b/src/server/config.ts index afcd04d..b819d9d 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -115,9 +115,13 @@ function parseBool(value: string | undefined, fallback: boolean): boolean { return ["1", "true", "yes", "on"].includes(value.toLowerCase()); } -function parsePort(value: string | undefined, fallback: number): number { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +function parseBoundedInteger(value: string | number | undefined, fallback: number, field: string, minimum: number, maximum: number): number { + if (value === undefined) return fallback; + const parsed = typeof value === "number" ? value : value.trim() ? Number(value) : Number.NaN; + if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${field} must be an integer between ${minimum} and ${maximum}.`); + } + return parsed; } function parsePositiveInteger(value: string | undefined, fallback: number): number { @@ -176,7 +180,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { dataDir, configPath, dbPath: resolve(explicitDbPath ?? defaultDbPath), - apiPort: parsePort(env.MOODARR_API_PORT, 4401), + apiPort: parseBoundedInteger(env.MOODARR_API_PORT, 4401, "MOODARR_API_PORT", 1, 65_535), apiHost, webOrigin, serveClient, @@ -209,7 +213,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { openaiReasoningEffort }, sync: { - intervalMinutes: parsePositiveInteger(env.MOODARR_SYNC_INTERVAL_MINUTES, persisted.sync?.intervalMinutes ?? 360), + intervalMinutes: parseBoundedInteger( + env.MOODARR_SYNC_INTERVAL_MINUTES ?? persisted.sync?.intervalMinutes, + 360, + env.MOODARR_SYNC_INTERVAL_MINUTES === undefined ? "sync.intervalMinutes" : "MOODARR_SYNC_INTERVAL_MINUTES", + 0, + 10_080 + ), syncSeerr: parseBool(env.MOODARR_SYNC_SEERR, persisted.sync?.syncSeerr ?? true) }, search: { diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..5a69135 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadConfig } from "../src/server/config"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("server configuration integer bounds", () => { + it.each([ + ["0", 0], + ["10080", 10_080] + ])("accepts MOODARR_SYNC_INTERVAL_MINUTES=%s", (value, expected) => { + expect(loadTestConfig({ MOODARR_SYNC_INTERVAL_MINUTES: value }).sync.intervalMinutes).toBe(expected); + }); + + it.each(["-1", "10081"])("rejects out-of-range MOODARR_SYNC_INTERVAL_MINUTES=%s", (value) => { + expect(() => loadTestConfig({ MOODARR_SYNC_INTERVAL_MINUTES: value })).toThrow( + "MOODARR_SYNC_INTERVAL_MINUTES must be an integer between 0 and 10080." + ); + }); + + it("rejects an out-of-range persisted sync interval when there is no environment override", () => { + const directory = mkdtempSync(join(tmpdir(), "moodarr-server-config-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "config.json"); + writeFileSync(configPath, JSON.stringify({ sync: { intervalMinutes: 10_081 } })); + + expect(() => loadTestConfig({}, directory)).toThrow("sync.intervalMinutes must be an integer between 0 and 10080."); + }); + + it.each([ + ["1", 1], + ["65535", 65_535] + ])("accepts MOODARR_API_PORT=%s", (value, expected) => { + expect(loadTestConfig({ MOODARR_API_PORT: value }).apiPort).toBe(expected); + }); + + it.each(["0", "65536"])("rejects out-of-range MOODARR_API_PORT=%s", (value) => { + expect(() => loadTestConfig({ MOODARR_API_PORT: value })).toThrow( + "MOODARR_API_PORT must be an integer between 1 and 65535." + ); + }); +}); + +function loadTestConfig(overrides: NodeJS.ProcessEnv, existingDirectory?: string) { + const directory = existingDirectory ?? mkdtempSync(join(tmpdir(), "moodarr-server-config-")); + if (!existingDirectory) temporaryDirectories.push(directory); + return loadConfig({ + MOODARR_DATA_DIR: directory, + MOODARR_CONFIG_PATH: join(directory, "config.json"), + MOODARR_FIXTURE_MODE: "true", + MOODARR_REQUIRE_ADMIN_TOKEN: "false", + MOODARR_API_HOST: "127.0.0.1", + ...overrides + }); +} diff --git a/tests/userRepository.test.ts b/tests/userRepository.test.ts index 1eb689d..8bb4f10 100644 --- a/tests/userRepository.test.ts +++ b/tests/userRepository.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { UserRepository } from "../src/server/auth/userRepository"; import { createDatabase } from "../src/server/db/database"; @@ -45,4 +48,36 @@ describe("UserRepository credential lifecycle", () => { expect(() => repository.upsertPlexUser({ providerUserId: "x".repeat(201) }, true)).toThrow(/safe account id/i); expect(() => repository.upsertPlexUser({ providerUserId: "another-user" }, true, "x".repeat(4_097))).toThrow(/credential was invalid/i); }); + + it("keeps valid and invalid session lookups read-only while another connection holds the writer lock", () => { + const directory = mkdtempSync(join(tmpdir(), "moodarr-user-session-contention-")); + const dbPath = join(directory, "moodarr.sqlite"); + const db = createDatabase(dbPath); + const repository = new UserRepository(db); + const validUser = repository.upsertPlexUser({ providerUserId: "valid-user", username: "valid" }, true); + const expiredUser = repository.upsertPlexUser({ providerUserId: "expired-user", username: "expired" }, true); + const validSession = repository.createSession(validUser.id); + const expiredSession = repository.createSession(expiredUser.id); + const unchangedLastSeenAt = "2000-01-01T00:00:00.000Z"; + db.prepare("UPDATE user_sessions SET last_seen_at = ? WHERE user_id = ?").run(unchangedLastSeenAt, validUser.id); + db.prepare("UPDATE user_sessions SET expires_at = ? WHERE user_id = ?").run("2000-01-01T00:00:00.000Z", expiredUser.id); + const writerDb = createDatabase(dbPath); + + writerDb.exec("BEGIN IMMEDIATE"); + const startedAt = Date.now(); + try { + expect(repository.findSessionUser(validSession.token)).toMatchObject({ id: validUser.id }); + expect(repository.findSessionUser("invalid-session-token")).toBeUndefined(); + expect(repository.findSessionUser(expiredSession.token)).toBeUndefined(); + expect(Date.now() - startedAt).toBeLessThan(1_000); + expect((db.prepare("SELECT last_seen_at FROM user_sessions WHERE user_id = ?").get(validUser.id) as { last_seen_at: string }).last_seen_at).toBe( + unchangedLastSeenAt + ); + } finally { + writerDb.exec("ROLLBACK"); + writerDb.close(); + db.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); }); From b8e040f8638beafc591a7d936673606c049ff751 Mon Sep 17 00:00:00 2001 From: Jarel Date: Tue, 21 Jul 2026 17:49:35 +1000 Subject: [PATCH 03/15] docs: reconcile beta evidence with published scope --- SUPPORT.md | 2 + docs/BETA_CANDIDATE_MANUAL_VALIDATION.md | 10 ++-- docs/BETA_RELEASE_CRITERIA.md | 27 ++++++++--- docs/COMPATIBILITY.md | 16 ++++--- docs/README.md | 8 ++-- docs/RELEASE.md | 16 +++++-- scripts/verify-doc-contracts.ts | 58 ++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 25 deletions(-) diff --git a/SUPPORT.md b/SUPPORT.md index 4174eda..1529afb 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,6 +2,8 @@ Moodarr is maintained as open-source self-hosted software. Published public betas are intended for real external use, but support is best effort and does not include a response-time or resolution-time guarantee. The GitHub Releases page is authoritative for which beta, if any, is currently supported. +Support policy and release evidence are separate. `v0.1.0-beta.1` was published from `08447e87df2e1705aa9a79193a52a65fb00724c3` under a narrower early-beta gate; [issue #32](https://github.com/jremick/moodarr/issues/32) remains the authoritative ledger for its open Unraid/update, stopped catalog, real integration-write, native responsiveness, current-browser, and privacy-reviewed manual-evidence work. A supported path is eligible for support and bug reports; it does not mean beta.1 completed every planned compatibility matrix. + ## Where To Ask - Search [existing issues](https://github.com/jremick/moodarr/issues) before opening a new report. diff --git a/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md b/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md index 31e8491..a108251 100644 --- a/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md +++ b/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md @@ -1,8 +1,10 @@ # Beta Candidate Manual Validation -This runbook defines the fail-closed manual evidence required for a Moodarr web/server beta candidate. It covers the release gates that cannot be established by fixture mode or a source build alone: an exact-digest Unraid installation, the exact beta catalog and its stopped networkless import and request-attempt boundary, real Plex and Seerr/Jellyseerr behavior, native Linux responsiveness, and the supported desktop browser and accessibility matrix. +This runbook preserves the original comprehensive manual gate designed for `v0.1.0-beta.1`. It covers evidence that cannot be established by fixture mode or a source build alone: an exact-digest Unraid installation, the exact beta catalog and its stopped networkless import and request-attempt boundary, real Plex and Seerr/Jellyseerr behavior, native Linux responsiveness, and the supported desktop browser and accessibility matrix. -The machine-readable contract in [`scripts/validate-beta-manual-evidence.ts`](../scripts/validate-beta-manual-evidence.ts) is authoritative for evidence shape and acceptance. Start from the structurally valid [`beta-manual-evidence-all-false.example.json`](beta-manual-evidence-all-false.example.json). The example is intentionally failing evidence, not a completed release artifact. Never change a `false` value to `true` until the exact candidate has passed that check and any required cleanup. +Beta.1 was published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3` under an intentionally narrower early-beta gate. [GitHub issue #32](https://github.com/jremick/moodarr/issues/32) is the authoritative record of actual evidence and follow-up. The comprehensive matrix was not completed for beta.1; its Unraid/update, stopped catalog, real integration writes, native responsiveness, current-browser, and privacy-reviewed artifact rows remain open. This runbook must not be read as evidence that they passed. + +The machine-readable contract in [`scripts/validate-beta-manual-evidence.ts`](../scripts/validate-beta-manual-evidence.ts) is authoritative for this beta.1-bound evidence shape and acceptance. Start from the structurally valid [`beta-manual-evidence-all-false.example.json`](beta-manual-evidence-all-false.example.json). The example is intentionally failing evidence, not a completed release artifact. Never change a `false` value to `true` until the exact candidate has passed that check and any required cleanup. The validator and example must be version-generalized before a beta.2 candidate; do not reuse them as beta.2 evidence unchanged. This evidence supplements the automated candidate workflow and the procedures in [Release](RELEASE.md). It does not replace clean-install, upgrade/rollback, supply-chain, vulnerability, or attestation evidence. Fixture, local-image, emulated-architecture, source, and EXP runs cannot close this manual gate. @@ -332,7 +334,7 @@ Do not convert a stop into a waiver inside the JSON. The manual validator's exit ## Acceptance Checklist -A candidate passes this manual gate only when all of the following are true: +Under this original comprehensive contract, a candidate passes the manual gate only when all of the following are true: - candidate and Unraid version, full revision, and immutable digest are identical; - catalog version, asset SHA-256, total records, and request-attempt eligible records match the pinned contract, and `exactAsset`, `networklessFullSnapshotImport`, `genericSearchIsolation`, and `requestAttemptDisclosure` are all `true`; @@ -352,4 +354,4 @@ A candidate passes this manual gate only when all of the following are true: --responsiveness-report "$responsiveness_report" ``` -Link the frozen summary and its reviewed evidence artifact from the candidate release ledger. This closes only the manual validation gate; final promotion still requires every other beta criterion and maintainer approval. +For a future candidate using this gate, link the frozen summary and its reviewed evidence artifact from that candidate's ledger. It closes only the manual validation gate; it does not retroactively qualify beta.1 or replace the actual beta.1 ledger in issue #32. diff --git a/docs/BETA_RELEASE_CRITERIA.md b/docs/BETA_RELEASE_CRITERIA.md index e71279c..89fc0a3 100644 --- a/docs/BETA_RELEASE_CRITERIA.md +++ b/docs/BETA_RELEASE_CRITERIA.md @@ -1,6 +1,21 @@ # Public Beta Release Criteria -This is the release gate for `v0.1.0-beta.1`. It is a planning and evidence document; its presence does not mean the beta gate has passed. +This document preserves the original comprehensive gate designed for `v0.1.0-beta.1`. It is a planning and future-hardening reference, not a claim that every row was completed. + +## Published Beta.1 Status + +`v0.1.0-beta.1` was published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3` under an intentionally narrower early-beta gate. The authoritative record of actual evidence and follow-up is [GitHub issue #32](https://github.com/jremick/moodarr/issues/32). The original comprehensive gate below did not pass as a whole and must not be read as passed. + +Six broad hardening rows remain open: + +- extra fresh Unraid install/update evidence; +- a stopped networkless catalog evidence package; +- a dedicated real Plex and Seerr/Jellyseerr write matrix; +- production native `linux/amd64` 2 CPU/2 GiB responsiveness evidence; +- a current Chrome/Edge/Firefox/Safari matrix; and +- a comprehensive privacy-reviewed manual artifact. + +Do not backfill this document to make those rows appear completed. Beta.1 release history is immutable; close or refine follow-up in issue #32 and apply the comprehensive gate to future hardening or a later candidate. The target is **Stage 3 - Public Beta**: external self-hosters can install, operate, upgrade, and report problems with clear expectations. It is not the stable `v1.0.0` contract. Stable API, longer deprecation, wider platform, and mature native-client commitments remain later work. @@ -20,9 +35,9 @@ Beta.1 includes: The beta compatibility surfaces are defined in [Compatibility](COMPATIBILITY.md). Except for the documented health semantics, the HTTP API is internal and does not become a stable third-party API in beta. -## Release Blockers +## Original Comprehensive Release Gate -Every row must pass unless this document explicitly permits a pre-candidate `Exception approved` decision. Applicable `Candidate validation`, `Pre-promotion`, and `Post-promotion` rows are non-waivable; a conditional row may be `Not applicable` only where its own criterion permits that status and the ledger records the rationale. +Under the original plan, every row had to pass unless this document explicitly permitted a pre-candidate `Exception approved` decision. Applicable `Candidate validation`, `Pre-promotion`, and `Post-promotion` rows were non-waivable; a conditional row could be `Not applicable` only where its own criterion permitted that status and the ledger recorded the rationale. These rules remain the future-hardening target, not a description of the narrower gate used to publish beta.1. | Gate | Required evidence | | --- | --- | @@ -79,9 +94,9 @@ These do not block the web/server beta unless a change regresses an already docu The experimental iOS client remains visible but must be labeled non-blocking and outside the beta support contract. Deferred native implementation and UI work is not part of the beta.1 candidate; only server-side API compatibility needed by the existing alpha client remains in scope. -## Release Evidence Ledger +## Original Planned Evidence Ledger -Create one ledger per release candidate in the release PR or release issue. Link durable CI runs, artifacts, logs, screenshots, benchmark summaries, and restore records rather than pasting secrets or private data. After the candidate is published, update that ledger without committing changes to the frozen candidate source; a source edit would require a new SHA candidate. +This template records the original comprehensive plan and is not beta.1's actual completion ledger. For beta.1, use [issue #32](https://github.com/jremick/moodarr/issues/32). For a future candidate, create one ledger in its release PR or release issue and link durable CI runs, artifacts, logs, screenshots, benchmark summaries, and restore records rather than pasting secrets or private data. After a candidate is published, update its external ledger without changing the frozen source. | Candidate metadata | Value | | --- | --- | @@ -133,7 +148,7 @@ Allowed statuses are `Pending`, `Passed`, `Failed`, `Not applicable`, and `Excep Every `Pre-candidate` row must be `Passed` or explicitly eligible for `Exception approved` before the full-SHA candidate workflow is authorized. Every applicable `Candidate validation`, `Pre-promotion`, and `Post-promotion` row must be `Passed`, not exception-approved. Candidate validation and pre-promotion must pass before the `beta-release` environment is approved; post-promotion must pass before the GitHub prerelease is published or announced. A draft may exist only long enough to stage and read back its immutable-release inputs. -## Promotion Decision +## Original Comprehensive Promotion Plan Promotion has four explicit decisions so the source commit does not need to contain evidence that can exist only after candidate publication: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 460bf03..0bf573d 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -4,14 +4,16 @@ This document defines the compatibility contract for published Moodarr `v0.1.0-b `Supported` means release-blocking defects can be reported against that configuration. `Best effort` means it may work but is not part of the release gate. `Experimental` means behavior and compatibility may change without a migration promise. +Compatibility is a support policy, not completed beta.1 evidence. `v0.1.0-beta.1` was published from `08447e87df2e1705aa9a79193a52a65fb00724c3` under a narrower early-beta gate. Its extra fresh Unraid/update, dedicated real Plex and Seerr/Jellyseerr write, and current Chrome/Edge/Firefox/Safari matrices remain open in the [authoritative beta.1 ledger](https://github.com/jremick/moodarr/issues/32). Supported configurations are appropriate for issue reports; this page does not claim every matrix was completed before beta.1 publication. + ## Deployment Matrix | Surface | Beta status | Contract | | --- | --- | --- | | Official container image | Supported | Linux `amd64` image published from the tagged commit. Other architectures are not published for beta. | -| Docker Engine | Supported | A current stable Docker Engine on Linux `amd64`, using the documented single-container configuration. The exact version used for each release is recorded in its release evidence. | -| Docker Compose | Supported | Docker Compose v2 with `docker-compose.example.yml`. Compose v1 is not supported. | -| Unraid | Supported | Unraid Docker Manager using the checked-in template. The exact Unraid version used for release validation is recorded with the release. | +| Docker Engine | Supported | A current stable Docker Engine on Linux `amd64`, using the documented single-container configuration. Report the exact Engine version with issues; release-specific install evidence belongs in that release's ledger. | +| Docker Compose | Supported | Docker Compose v2 with `docker-compose.example.yml`. Compose v1 is not supported. Report the exact Compose version with issues; release-specific install evidence belongs in the ledger. | +| Unraid | Supported | Unraid Docker Manager using the checked-in template. Report the exact Unraid version with issues; release-specific validation evidence belongs in that release's ledger. | | Source development | Supported for contributors | Node.js 24 or newer and `npm ci`, as declared by `package.json`. Native source deployment is not a beta production target. | | macOS or Windows host deployment | Best effort | Development and Docker Desktop may work, but the released server target is Linux `amd64`. Windows containers are not supported. | | Moodarr iOS app | Experimental | Native-client work does not block the web/server beta and is not included in the beta compatibility promise. | @@ -27,14 +29,14 @@ The beta web app supports the current stable desktop releases of: - Firefox; and - Safari on macOS. -The release candidate must complete its browser and accessibility smoke matrix against the exact recorded current-stable versions. Immediately previous major releases are best effort rather than release-blocking. Browsers on iOS and other mobile platforms, embedded webviews, and older desktop releases are also best effort for beta. The native Moodarr iOS app remains experimental under the deployment matrix above and does not expand the web compatibility promise. +This is the support target for current releases, not a claim that beta.1 completed the full matrix. Future candidates should record the exact current-stable versions used for browser and accessibility smoke testing. Immediately previous major releases are best effort rather than release-blocking. Browsers on iOS and other mobile platforms, embedded webviews, and older desktop releases are also best effort for beta. The native Moodarr iOS app remains experimental under the deployment matrix above and does not expand the web compatibility promise. ## Integration Matrix | Integration | Beta status | Contract | | --- | --- | --- | -| Plex Media Server | Supported | Library sync, Plex sign-in, poster proxying, Plex links, and signed-in-user Watchlist actions are tested against the current stable Plex release used by the release candidate. Record that exact version. | -| Seerr or Jellyseerr | Supported | Operational request-state sync and explicitly confirmed request creation are tested against the current stable release used by the release candidate. Moodarr does not use Seerr as a descriptive discovery catalog or claim an unverified catalog title is requestable. Record the product and exact version. | +| Plex Media Server | Supported | Maintained flows cover library sync, Plex sign-in, poster proxying, Plex links, and explicit signed-in-user Watchlist actions. Record the exact server version with issues; release-specific write-matrix evidence belongs in the ledger. | +| Seerr or Jellyseerr | Supported | Maintained flows cover operational request-state sync and explicitly confirmed request creation. Moodarr does not use Seerr as a descriptive discovery catalog or claim an unverified catalog title is requestable. Record the product and exact version with issues; release-specific write-matrix evidence belongs in the ledger. | | Other Seerr-compatible servers | Best effort | API-compatible deployments may work, but untested variants do not expand the beta support contract. | | Beta.1 Wikidata catalog asset | Supported and optional | Plex-only operation works without the asset. Missing-title discovery uses only `wikidata-20260622-min5-v1`, SHA-256 `dd25ba6602e1bdb8e6999b0442bc40165e6d4faadd02e91e74e1a24e2b55e85a`, imported through the stopped networkless full-snapshot procedure in [Catalog Bootstrap](CATALOG_BOOTSTRAP.md). Regenerated or newer datasets are best effort. | | Local recommendation processing | Supported | The official beta.1 image bakes in non-overridable provider policy `none`; no provider credential or provider network access is part of the release path. | @@ -43,7 +45,7 @@ The release candidate must complete its browser and accessibility smoke matrix a | Other AI providers or OpenAI-compatible endpoints | Unsupported | No compatibility promise is made unless a provider is explicitly documented. | | Fixture mode | Supported for evaluation | Fixture mode is part of development, CI, and first-look testing. It is not evidence that a real Plex/Seerr deployment has been validated. | -Third-party services do not publish perfectly synchronized compatibility contracts. Each Moodarr release therefore records the exact Plex and Seerr/Jellyseerr versions used for its integration evidence instead of implying support for every historical version. Third-party content and service terms remain separate from Moodarr's Apache License 2.0 and must be rechecked for each release. +Third-party services do not publish perfectly synchronized compatibility contracts. Candidate plans should record the exact Plex and Seerr/Jellyseerr versions used for integration evidence instead of implying support for every historical version. The absence of a completed beta.1 matrix remains visible in issue #32. Third-party content and service terms remain separate from Moodarr's Apache License 2.0 and must be rechecked for each release. The optional catalog asset contains 90,397 importable CC0 Wikidata records. Of those, 82,865 meet beta.1's ambiguity-safe local request-attempt prerequisites: 70,841 movies and 12,024 TV series. Thirty-six groups share a strong importer identifier across 72 source records. Fifty-nine of those records—10 movies and 49 TV series—otherwise meet attempt requirements; their ambiguous catalog materializations remain imported and indexed for provenance and diagnostics but cannot independently surface in Finder or authorize request preview or creation. An independently identified available Plex item remains Finder-visible if linked later, while ambiguity still blocks every request action. This is catalog coverage, not verified Seerr availability. Unambiguous eligible catalog-only rows remain `unavailable`; ordinary generic search and verified-requestable-only filters exclude them. A narrowly explicit request-attempt search may include an eligible row with **Availability not checked**, and Seerr may reject the confirmed attempt. diff --git a/docs/README.md b/docs/README.md index 1847faf..dfadf98 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ This is the curated entry point for Moodarr's supported web/server documentation. Start with the deployment and operations guides below; planning and research files elsewhere in this directory are contributor references, not additional beta support promises. +`v0.1.0-beta.1` is published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3`. [GitHub issue #32](https://github.com/jremick/moodarr/issues/32) is the authoritative evidence and follow-up ledger. Compatibility describes current support policy; it is not a claim that every beta.1 Unraid, integration, browser, responsiveness, catalog, or manual-evidence matrix was completed. + ## Install And Operate - [Compatibility](COMPATIBILITY.md) - supported deployment, browser, integration, storage, and network boundaries. @@ -20,9 +22,9 @@ This is the curated entry point for Moodarr's supported web/server documentation ## Release And Maintainer Guides -- [Public beta release criteria](BETA_RELEASE_CRITERIA.md) - blocking requirements and the evidence ledger. -- [Beta candidate manual validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) - exact-candidate operator validation. -- [Release readiness](RELEASE.md) - automated gates, packaging, and promotion. +- [Public beta release criteria](BETA_RELEASE_CRITERIA.md) - original comprehensive beta.1 plan and future-hardening reference. +- [Beta candidate manual validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) - original beta.1-bound operator runbook; not completed beta.1 evidence. +- [Release readiness](RELEASE.md) - current release truth plus the preserved comprehensive release process. - [Production plan](PRODUCTION_PLAN.md) - production architecture, security rules, and longer-term hardening. ## Design And Contribution diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 2db1f44..115e469 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -2,6 +2,12 @@ Moodarr's early-public-beta release process uses protected Git tags, immutable GitHub prereleases, and workflow-append-only GHCR version tags published from exact verified commits and bound to recorded immutable image digests. +## Current Release Truth + +`v0.1.0-beta.1` was published from source commit `08447e87df2e1705aa9a79193a52a65fb00724c3` under an intentionally narrower early-beta gate. [GitHub issue #32](https://github.com/jremick/moodarr/issues/32) is the authoritative actual evidence and follow-up ledger. Extra fresh Unraid/update, stopped networkless catalog, dedicated real Plex and Seerr/Jellyseerr writes, production native `linux/amd64` 2 CPU/2 GiB responsiveness, current Chrome/Edge/Firefox/Safari, and comprehensive privacy-reviewed manual evidence remain open; do not infer completion from the published tag. + +The comprehensive beta.1 procedure below is preserved as the original plan and future-hardening reference. It does not rewrite immutable beta.1 history or claim every planned gate passed. The beta install, upgrade, responsiveness, and manual-evidence validators remain beta.1-bound and must be version-generalized before a beta.2 candidate. + ## Local Release Gate ```bash @@ -36,7 +42,7 @@ Every candidate image includes maximum BuildKit provenance, an SPDX SBOM, and a GHCR's manifest-tag API does not provide this workflow with a guaranteed atomic create-only write. Candidate mode checks that its full-SHA tag is absent before pushing and then performs the anonymous raw-manifest self-readback, but a separately authorized package writer can still race either observation. Any candidate run that fails after the full-SHA tag appears is abandoned: do not delete, overwrite, or reuse that tag; merge and approve a new source commit instead. Promotion reads the version tag immediately before writing: a `404` permits one manifest PUT, a `200` permits no write and is accepted only when the existing registry digest, recomputed digest, media type, and raw bytes exactly match the approved candidate, and every other result fails. Registry token and manifest reads use bounded timeouts and retry-safe retries. Every token response is captured in a mode-`0600` temporary file and must contain exactly one bounded, safe-shape token before masking or use; retry-contaminated, multiline, or malformed responses fail closed without entering an authorization header. The manifest PUT is bounded but deliberately not retried automatically: an uncertain write is recovered only through a new Tier 3-approved promotion dispatch, which can adopt the exact existing bytes without rewriting them. Promotion re-reads both candidate and version manifests afterward and requires the protected semantic Git tag to stay absent through the approved job. If a PUT succeeds but a later network or read-back step fails, start a new `release_mode=promotion` dispatch with the same SHA and digest and obtain the `beta-release` approval again; the workflow will adopt the exact existing tag without rewriting it and repeat all final checks. Repository package-write permission must remain restricted. A separate privileged package writer can still race the final registry request because GHCR offers no atomic create-only condition; any mismatched pre-existing or final content fails closed. Restrict package writers and review the final digest read-back. -## Two-Stage Beta Promotion +## Original Comprehensive Two-Stage Beta Promotion 1. Freeze the release-ready source commit as the current `main` HEAD. Package version, changelog, README, Compose, Unraid template, and support/security copy must already be valid release copy, while GitHub Releases remains the source of truth for whether the version is publicly available. 2. Complete the pre-candidate evidence rows, then manually dispatch `publish-image.yml` from `main` with `release_mode=candidate`, that HEAD's full 40-character commit SHA, and an empty `candidate_digest`. If `main` advances before dispatch, review and freeze the new HEAD and publish a new candidate from it; do not move `main` backward solely for publication. Require the candidate job's pre-push semantic Git-tag absence check, anonymous full-SHA-tag/digest raw-manifest self-readback, and semantic GHCR version-tag `404` to pass, then record the full-SHA image, emitted digest, and successful workflow run. @@ -415,7 +421,7 @@ Exit status is `0` only when the beta.1 evidence passes: at least 100 health, 20 ### Candidate Manual Evidence -Use [Beta Candidate Manual Validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) as the canonical fail-closed procedure for the evidence that fixture and source-built rehearsals cannot establish: exact-digest Unraid behavior, the exact catalog asset and stopped networkless full-snapshot import, request-attempt search/disclosure isolation, real Plex and Seerr/Jellyseerr writes and cleanup, the native responsiveness report hash, and the current-stable desktop browser/accessibility matrix. Start from its tracked all-false example and validate the completed privacy-reviewed file with `npm run validate:beta-manual-evidence`. The CLI binds the responsiveness harness hash to the canonical script blob at the expected Git revision, but the resulting matrix remains a structured operator attestation requiring maintainer review rather than independent automated proof. Only validator exit `0` against the same immutable candidate can close those ledger rows; local images, source/EXP runs, emulation, and evidence inherited from another digest remain ineligible. +Use [Beta Candidate Manual Validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) as the original fail-closed procedure for evidence that fixture and source-built rehearsals cannot establish: exact-digest Unraid behavior, the exact catalog asset and stopped networkless full-snapshot import, request-attempt search/disclosure isolation, real Plex and Seerr/Jellyseerr writes and cleanup, the native responsiveness report hash, and the current-stable desktop browser/accessibility matrix. Start from its tracked all-false example and validate a completed privacy-reviewed file with `npm run validate:beta-manual-evidence`. The CLI binds the responsiveness harness hash to the canonical script blob at the expected Git revision, but the resulting matrix remains a structured operator attestation requiring maintainer review rather than independent automated proof. This comprehensive manual gate remains open for beta.1; validator exit `0` was the original completion rule, not a retroactive publication claim. Local images, source/EXP runs, emulation, and evidence inherited from another digest remain ineligible. ## Pre-Release Checklist @@ -439,10 +445,10 @@ Use [Beta Candidate Manual Validation](BETA_CANDIDATE_MANUAL_VALIDATION.md) as t - Repository visibility: public. - License: Apache-2.0. - Security reporting: GitHub private vulnerability reporting. -- Target release image: `ghcr.io/jremick/moodarr:v0.1.0-beta.1`. -- Target GitHub prerelease: `v0.1.0-beta.1`. +- Published release image: `ghcr.io/jremick/moodarr:v0.1.0-beta.1`. +- Published GitHub prerelease: `v0.1.0-beta.1`, source commit `08447e87df2e1705aa9a79193a52a65fb00724c3`. - Optional catalog release asset: `moodarr-wikidata-20260622-min5-v1.jsonl.gz`, catalog version `wikidata-20260622-min5-v1`, SHA-256 `dd25ba6602e1bdb8e6999b0442bc40165e6d4faadd02e91e74e1a24e2b55e85a`. -- GitHub Releases is authoritative for whether this target is available; source references alone do not mean it has been published. +- GitHub Releases is authoritative for release availability; [issue #32](https://github.com/jremick/moodarr/issues/32) is authoritative for beta.1 evidence and open follow-up. - Future changes stay under `Unreleased` until a new protected Git tag, workflow-append-only GHCR version tag, and immutable GitHub prerelease are intentionally created. ## Supply-Chain Posture diff --git a/scripts/verify-doc-contracts.ts b/scripts/verify-doc-contracts.ts index 31b6f5a..182b7cb 100644 --- a/scripts/verify-doc-contracts.ts +++ b/scripts/verify-doc-contracts.ts @@ -28,6 +28,7 @@ const responsivenessHarness = read("scripts/benchmark-beta-responsiveness.ts"); const compatibility = read("docs/COMPATIBILITY.md"); const betaManualValidation = read("docs/BETA_CANDIDATE_MANUAL_VALIDATION.md"); const betaManualEvidenceExample = read("docs/beta-manual-evidence-all-false.example.json"); +const docsIndex = read("docs/README.md"); const unraidGuide = read("docs/UNRAID.md"); const changelog = read("CHANGELOG.md"); const parsedBugReport = loadYaml(bugReportTemplate) as { body?: unknown }; @@ -256,6 +257,63 @@ for (const [path, content, phrases] of [ if (!content.includes(phrase)) failures.push(`${path} does not contain the native source validation contract: ${phrase}`); } } +const beta1SourceRevision = "08447e87df2e1705aa9a79193a52a65fb00724c3"; +const beta1LedgerUrl = "https://github.com/jremick/moodarr/issues/32"; +for (const [path, content] of [ + ["docs/BETA_RELEASE_CRITERIA.md", betaReleaseCriteria], + ["docs/BETA_CANDIDATE_MANUAL_VALIDATION.md", betaManualValidation], + ["docs/COMPATIBILITY.md", compatibility], + ["docs/RELEASE.md", releaseGuide], + ["docs/README.md", docsIndex], + ["SUPPORT.md", support] +] as const) { + if (!content.includes(beta1SourceRevision)) failures.push(`${path} does not identify the immutable beta.1 source revision`); + if (!content.includes(beta1LedgerUrl)) failures.push(`${path} does not link the authoritative beta.1 evidence ledger`); +} +for (const phrase of [ + "intentionally narrower early-beta gate", + "original comprehensive gate below did not pass as a whole", + "extra fresh Unraid install/update evidence", + "stopped networkless catalog evidence package", + "dedicated real Plex and Seerr/Jellyseerr write matrix", + "production native `linux/amd64` 2 CPU/2 GiB responsiveness evidence", + "current Chrome/Edge/Firefox/Safari matrix", + "comprehensive privacy-reviewed manual artifact", + "Beta.1 release history is immutable" +]) { + if (!betaReleaseCriteria.includes(phrase)) failures.push(`docs/BETA_RELEASE_CRITERIA.md does not preserve beta.1 release truth: ${phrase}`); +} +for (const [path, content, phrases] of [ + [ + "docs/BETA_CANDIDATE_MANUAL_VALIDATION.md", + betaManualValidation, + ["must not be read as evidence that they passed", "version-generalized before a beta.2 candidate"] + ], + [ + "docs/COMPATIBILITY.md", + compatibility, + ["Compatibility is a support policy, not completed beta.1 evidence", "does not claim every matrix was completed before beta.1 publication"] + ], + [ + "docs/RELEASE.md", + releaseGuide, + ["do not infer completion from the published tag", "does not rewrite immutable beta.1 history", "must be version-generalized before a beta.2 candidate"] + ], + [ + "docs/README.md", + docsIndex, + ["Compatibility describes current support policy", "not a claim that every beta.1"] + ], + [ + "SUPPORT.md", + support, + ["Support policy and release evidence are separate", "does not mean beta.1 completed every planned compatibility matrix"] + ] +] as const) { + for (const phrase of phrases) { + if (!content.includes(phrase)) failures.push(`${path} does not preserve the beta.1 evidence/support boundary: ${phrase}`); + } +} if (packageScripts["bench:beta-responsiveness"] !== "tsx scripts/benchmark-beta-responsiveness.ts") { failures.push("package.json does not expose the beta responsiveness benchmark command"); } From 20bc6d927a3811e085e3d75aa12502fff43bb285 Mon Sep 17 00:00:00 2001 From: Jarel Date: Tue, 21 Jul 2026 17:51:19 +1000 Subject: [PATCH 04/15] fix(web): add recoverable server bootstrap state --- src/client/App.tsx | 129 ++++++++++++++++++++++++++------- src/client/api.ts | 130 +++++++++++++++++++++++++++++----- src/client/styles.css | 58 +++++++++++++++ tests/clientApi.test.ts | 110 ++++++++++++++++++++++++++-- tests/clientBootstrap.test.ts | 42 ++++++++++- 5 files changed, 423 insertions(+), 46 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index ad33154..3ce908b 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -78,11 +78,17 @@ const groupOrder: FinderAvailabilityGroup[] = [ "unavailable" ]; +type BootstrapConnectionState = + | { phase: "checking" } + | { phase: "ready" } + | { phase: "unavailable"; message: string }; + export function App() { const [activeView, setActiveView] = useState(() => activeViewFromPathname(window.location.pathname)); const [adminCapability, setAdminCapability] = useState("unknown"); const [adminTokenDraft, setAdminTokenDraft] = useState(""); const [status, setStatus] = useState(null); + const [bootstrapConnection, setBootstrapConnection] = useState({ phase: "checking" }); const [authSession, setAuthSession] = useState(null); const [pendingPlexAuth, setPendingPlexAuth] = useState(() => loadPendingPlexAuth(window.localStorage)); const [stats, setStats] = useState(null); @@ -118,6 +124,7 @@ export function App() { const adminLoadRequestedRef = useRef(false); const baseScoreByItemIdRef = useRef>({}); const previousDefaultResultLimitRef = useRef(defaultSearchResultLimit); + const statusRefreshGenerationRef = useRef(0); const searchRequestRef = useRef(null); searchRequestRef.current ??= new LatestRequestLifecycle(); const actionLockRef = useRef(null); @@ -151,13 +158,13 @@ export function App() { } = useAdminConsole(runAction, handleSyncSettled); useEffect(() => { - void refreshStatus(); + void refreshStatus().catch(() => undefined); return () => searchRequestRef.current?.abort(); }, []); useEffect(() => { const refreshVisibleSession = () => { - if (document.visibilityState === "visible") void refreshStatus(); + if (document.visibilityState === "visible") void refreshStatus().catch(() => undefined); }; window.addEventListener("focus", refreshVisibleSession); document.addEventListener("visibilitychange", refreshVisibleSession); @@ -260,28 +267,44 @@ export function App() { }, [status?.runtime.defaultResultLimit, hasSearchSession]); async function refreshStatus() { - const [adminSession, configStatus, session] = await Promise.all([ - moodarrApi.adminSession().catch(() => null), - moodarrApi.configStatus(), - moodarrApi.authSession().catch(() => null) - ]); - setAdminCapability((current) => (adminSession ? (adminSession.ok ? "available" : "unavailable") : current === "unknown" ? "unavailable" : current)); - const libraryStats = canLoadLibraryStats({ - adminSessionAvailable: Boolean(adminSession?.ok), - adminAuthRequired: configStatus.admin.authRequired, - userAuthenticated: Boolean(session?.authenticated) - }) - ? await moodarrApi.stats().catch(() => null) - : null; - setStatus(configStatus); - setStats(libraryStats); - setAuthSession(session); - if (session?.authenticated) { - clearPendingPlexAuth(window.localStorage); - setPendingPlexAuth(null); + const generation = ++statusRefreshGenerationRef.current; + try { + const [adminSession, configStatus, session] = await Promise.all([ + moodarrApi.adminSession().catch(() => null), + moodarrApi.configStatus(), + moodarrApi.authSession().catch(() => null) + ]); + const libraryStats = canLoadLibraryStats({ + adminSessionAvailable: Boolean(adminSession?.ok), + adminAuthRequired: configStatus.admin.authRequired, + userAuthenticated: Boolean(session?.authenticated) + }) + ? await moodarrApi.stats().catch(() => null) + : null; + if (generation !== statusRefreshGenerationRef.current) return; + setAdminCapability((current) => (adminSession ? (adminSession.ok ? "available" : "unavailable") : current === "unknown" ? "unavailable" : current)); + setStatus(configStatus); + setStats(libraryStats); + setAuthSession(session); + setBootstrapConnection({ phase: "ready" }); + if (session?.authenticated) { + clearPendingPlexAuth(window.localStorage); + setPendingPlexAuth(null); + } + } catch (error) { + if (generation === statusRefreshGenerationRef.current) { + setAdminCapability((current) => current === "unknown" ? "unavailable" : current); + setBootstrapConnection({ phase: "unavailable", message: describeBootstrapFailure(error) }); + } + throw error; } } + function retryBootstrap() { + setBootstrapConnection({ phase: "checking" }); + void refreshStatus().catch(() => undefined); + } + async function handleSyncSettled(finalStatus: SyncStatus) { await refreshStatus(); if (finalStatus.lastResult && !finalStatus.lastResult.ok) { @@ -794,10 +817,12 @@ export function App() { > Skip to {activeView === "finder" ? "Finder" : activeView === "review" ? "Review Queue" : "Admin"} - {activeView !== "finder" || finderAccessBlocked ? ( + {activeView !== "finder" || finderAccessBlocked || bootstrapConnection.phase !== "ready" ? (
- +