From a41c8b5fa85419231567fc0925a285c877cf9b7d Mon Sep 17 00:00:00 2001 From: sapientorius Date: Mon, 2 Nov 2026 13:36:23 +0100 Subject: [PATCH 1/3] feat: enhance setup process with improved error handling and user feedback --- docs/OPERATIONS.md | 10 ++ packages/server/src/app.ts | 92 ++++++++++--- packages/web/src/App.vue | 43 +++++- packages/web/src/api.ts | 10 +- packages/web/src/setup-errors.ts | 4 + packages/web/src/setup-request.ts | 25 ++++ packages/web/src/stores/session.ts | 1 + packages/web/src/views/LoginView.vue | 9 +- tests/setup-http.test.ts | 194 ++++++++++++++++++++++++--- tests/setup-request.test.ts | 144 ++++++++++++++++++++ 10 files changed, 488 insertions(+), 44 deletions(-) create mode 100644 packages/web/src/setup-request.ts create mode 100644 tests/setup-request.test.ts diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 751b630..23cc3f9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -123,6 +123,16 @@ Mount inputs read-only and make them readable only by app UID 65532. Keep sensit - Follow [Updates and upgrades](UPGRADING.md) for the maintenance procedure, including Coolify/Dokploy image selection, backup/restore, startup migrations and post-update checks. - After an incomplete takeover, preserve the partial DB and restore the original backup separately before pointing sysPass at it. See [rollback](MIGRATION.md#verification-and-rollback). +### Setup stops responding or reappears after import + +Setup is selected by the server's `/api/v1/status` response, not by browser display preferences or saved passkeys. An old session cookie can require another sign-in, and a passkey from a deleted installation needs registration in the new installation; neither changes the database's initialization flag. + +The browser bounds each setup HTTP exchange to 60 seconds. Import and takeover jobs can run longer while their status requests continue to respond. A response timeout ends the browser's wait only: the server job may still be active. Reload to check the installation state before submitting again, and preserve the database, data volume and deployment secrets. If legacy data has already been imported, continue takeover without uploading the dump again. + +Compare `/api/v1/status`, `/health/live` and `/health/ready` on the same public origin. If status reports `setupRequired: true` after a completed setup, verify that the domain routes to the intended single app process and database, and check container restarts, volume mappings and the deployment key. If even liveness does not respond, inspect the app container and ingress before retrying imports. Setup logs contain `setup.started`, `setup.progress`, `setup.completed` and `setup.failed` events correlated by a reference, without uploaded data, credentials or job tokens. Keep the last phase and reference when reporting a failure. + +An app process that was waiting for setup rechecks the database before reporting status or accepting setup requests. If another process initialized the database meanwhile, it returns HTTP 503 with `SETUP_STATE_CHANGED`, logs `setup.state.changed` and blocks application access instead of offering setup again. Liveness stays available and readiness reports `restart-required`. Stop duplicate app instances and restart the intended single app with the same database, volume and secrets. This guard does not add support for active replicas. + ## Release and hosting checks Follow the [release procedure](RELEASES.md) for version preparation, repository rules, approval, immutable assets and digest-pinned deployment. Release candidates never advance the stable `latest` image tag. diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index f3b4e71..16356f4 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -127,6 +127,13 @@ export async function createApp( let status = maintenance ? { installed: false, legacy: false, ready: false } : await migration.status(); + let setupStateChanged = false; + let freshSetupActive = false; + const changedSetupState = () => + new Unavailable( + "The database was initialized after this app process started. Restart this app and verify that only one app instance uses this database. Keep the database, data volume and deployment secrets.", + { code: "SETUP_STATE_CHANGED" }, + ); let updates = new Updates( db, integrations, @@ -251,6 +258,13 @@ export async function createApp( throw new Forbidden("Untrusted proxy headers"); if (config.production && !ctx.secure && !ctx.path.startsWith("/health/")) throw new Forbidden("HTTPS is required"); + if ( + ctx.path === "/api/v1/status" || + ctx.path.startsWith("/api/v1/setup/") + ) + await reconcileSetupState(); + if (setupStateChanged && application && !restoreStatus) + throw changedSetupState(); if ( ctx.path.startsWith("/api/v1/setup/") && !["GET", "HEAD"].includes(ctx.method) @@ -334,10 +348,16 @@ export async function createApp( ].includes(error.data?.code) ? { code: error.data.code } : {}), - ...(error instanceof Unavailable && error.data?.code === "MAINTENANCE" - ? { code: "MAINTENANCE" } + ...(error instanceof Unavailable && + ["MAINTENANCE", "SETUP_STATE_CHANGED"].includes(error.data?.code) + ? { code: error.data.code } : {}), }; + if ( + error instanceof Unavailable && + error.data?.code === "SETUP_STATE_CHANGED" + ) + ctx.body.message = error.message; if (ctx.status >= 500) console.error( JSON.stringify({ @@ -422,14 +442,39 @@ export async function createApp( }; const setupJobs = new Map(), setupTasks = new Set>(); + const canReconcileSetup = () => + !maintenance && + !status.ready && + !setupStateChanged && + !freshSetupActive && + !setupTasks.size; + const reconcileSetupState = async () => { + if (!canReconcileSetup()) return; + const current = await migration.status(); + // A concurrent local setup can complete while the status query is in flight. + if (!canReconcileSetup()) return; + if (current.ready) { + setupStateChanged = true; + console.error(JSON.stringify({ event: "setup.state.changed" })); + } else status = current; + }; const queueSetup = (kind: string, work: () => Promise): SetupJob => { - if ([...setupJobs.values()].some((job) => job.state === "running")) + if ( + freshSetupActive || + [...setupJobs.values()].some((job) => job.state === "running") + ) throw new Conflict("A setup job is already active"); if (setupJobs.size >= 5) setupJobs.delete(setupJobs.keys().next().value!); const job: SetupJob = { id: token(), kind, step: kind, state: "running" }; + const reference = randomUUID(); + const logProgress = (event: string) => + console.info(JSON.stringify({ event, reference, kind, step: job.step })); setupJobs.set(job.id, job); + logProgress("setup.started"); migration.onProgress = (step) => { + if (step === job.step) return; job.step = step; + logProgress("setup.progress"); }; const task = (async () => { try { @@ -438,11 +483,12 @@ export async function createApp( await startJobs(); job.state = "complete"; job.step = "saved"; + logProgress("setup.completed"); } catch (error) { job.state = "failed"; job.error = { ...setupFailure(error), - reference: randomUUID(), + reference, step: job.step, }; console.error( @@ -486,13 +532,18 @@ export async function createApp( ctx.body = { status: "database-unavailable" }; return; } - ctx.status = !maintenance && status.ready && vault.unlocked ? 200 : 503; + ctx.status = + !maintenance && !setupStateChanged && status.ready && vault.unlocked + ? 200 + : 503; ctx.body = { status: maintenance ? "maintenance" - : ctx.status === 200 - ? "ready" - : "setup-required", + : setupStateChanged + ? "restart-required" + : ctx.status === 200 + ? "ready" + : "setup-required", }; }); router.get("/api/v1/status", async (ctx) => { @@ -528,15 +579,22 @@ export async function createApp( await setup(ctx); const data = body(ctx); exact(data, ["login", "name", "password"]); - await migration.fresh({ - login: text(data, "login", 50), - name: text(data, "name", 80), - password: text(data, "password"), - token: ctx.get("x-setup-token"), - }); - status = await migration.status(); - await startJobs(); - ctx.body = { ready: status.ready }; + if (freshSetupActive || setupTasks.size) + throw new Conflict("A setup job is already active"); + freshSetupActive = true; + try { + await migration.fresh({ + login: text(data, "login", 50), + name: text(data, "name", 80), + password: text(data, "password"), + token: ctx.get("x-setup-token"), + }); + status = await migration.status(); + await startJobs(); + ctx.body = { ready: status.ready }; + } finally { + freshSetupActive = false; + } }); router.post("/api/v1/setup/migrate", async (ctx) => { await setup(ctx); diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 3ccd632..0aced0e 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -28,6 +28,7 @@ import { cancelConfirmation } from "./confirmation"; import { uiLocales, dateLocales } from "./ui-locale"; import type { Language, UpdateStatus } from "@nodepass/contracts"; import { api } from "./api"; +import { setupErrorMessage } from "./setup-errors"; import { canManage, managementNavigation } from "./navigation"; export default defineComponent({ components: { @@ -61,6 +62,16 @@ export default defineComponent({ poller: undefined as ReturnType | undefined, }), computed: { + setupRestartMessage() { + return setupErrorMessage( + { + code: "SETUP_STATE_CHANGED", + message: + "The database was initialized after this app process started. Restart this app and verify that only one app instance uses this database. Keep the database, data volume and deployment secrets.", + }, + String(this.$i18n.locale), + ); + }, componentLocale() { return uiLocales[this.$i18n.locale as Language]; }, @@ -172,6 +183,7 @@ export default defineComponent({ window.addEventListener("nodepass-expired", this.expired); window.addEventListener("nodepass-refresh", this.refreshSession); window.addEventListener("nodepass-maintenance", this.enterMaintenance); + window.addEventListener("nodepass-restart-required", this.requireRestart); window.addEventListener("focus", this.activated); document.addEventListener("visibilitychange", this.activated); await this.sessionStore.load(); @@ -187,10 +199,18 @@ export default defineComponent({ window.removeEventListener("nodepass-expired", this.expired); window.removeEventListener("nodepass-refresh", this.refreshSession); window.removeEventListener("nodepass-maintenance", this.enterMaintenance); + window.removeEventListener( + "nodepass-restart-required", + this.requireRestart, + ); window.removeEventListener("focus", this.activated); document.removeEventListener("visibilitychange", this.activated); }, methods: { + requireRestart() { + this.sessionStore.restartRequired = true; + this.sessionStore.clear(); + }, enterMaintenance() { this.sessionStore.maintenance = true; cancelConfirmation(); @@ -291,7 +311,7 @@ export default defineComponent({ class="app-root" :class="{ 'is-dark': sessionStore.theme === 'dark' }" > - + - {{ - sessionStore.error - }} +
+ {{ + sessionStore.restartRequired + ? setupRestartMessage + : sessionStore.error + }}
- + + +
+ {{ + $t(recoveryMode ? "useAuthenticatorCode" : "useRecoveryCode") + }} +