From dff4eddd73be2ef69991ef983fbca2e169301534 Mon Sep 17 00:00:00 2001 From: Randy Hammond Date: Wed, 29 Apr 2026 00:36:52 +0000 Subject: [PATCH 1/3] ci(release): auto-create GitHub Release on tag push The Release binaries workflow ran 'gh release upload' assuming the release already existed, which failed for v1.3.0 with 'release not found' because the tag was pushed via 'git push origin v1.3.0' rather than 'gh release create'. Add a guard that creates the release with --generate-notes if it doesn't exist, then proceeds to upload. Future tag pushes publish artifacts without manual intervention. --- .github/workflows/release.yml | 16 ++++++++++++++++ CHANGELOG.md | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8c0a46..5ce537a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,6 +199,22 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Create the release if a tag was pushed without one (e.g. tag + # pushed via `git push --tags` rather than `gh release create`). + if ! gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes + fi + # Create the release if a tag was pushed without one (e.g. tag + # pushed via `git push --tags` rather than `gh release create`). + if ! gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes + fi gh release upload "${GITHUB_REF_NAME}" dist/* \ --repo "${GITHUB_REPOSITORY}" \ --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index 424e07d..229b901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Release workflow now creates the GitHub Release when a tag is pushed without one, instead of failing with `release not found` when uploading binaries. Future `git push --tags` releases publish artifacts without a manual `gh release create` step. + ## [1.3.0] — 2026-04-29 ### Added From 7861341c184c0d7ee4a46379d9d0cb6c023ef2f6 Mon Sep 17 00:00:00 2001 From: Randy Hammond Date: Wed, 29 Apr 2026 00:58:00 +0000 Subject: [PATCH 2/3] fix(auth): single-flight silent refresh to prevent family revocation on tab wake --- CHANGELOG.md | 1 + frontend/src/app/api.ts | 78 +++++++++++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 229b901..91fe34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Idle browser tabs no longer get logged out when the access JWT expires. The frontend now single-flights `POST /api/v1/auth/refresh` so simultaneous 401s on tab wake (config, calls list, listener TG selection, WebSocket reauth, scheduled refresh, etc.) coalesce onto one network request. Previously, parallel refresh attempts presented the same single-use refresh token; the server treated the second attempt as a replay and revoked the entire token family, forcing a re-login despite the 30-day refresh cookie. - Release workflow now creates the GitHub Release when a tag is pushed without one, instead of failing with `release not found` when uploading binaries. Future `git push --tags` releases publish artifacts without a manual `gh release create` step. ## [1.3.0] — 2026-04-29 diff --git a/frontend/src/app/api.ts b/frontend/src/app/api.ts index 87f394b..4c682e6 100644 --- a/frontend/src/app/api.ts +++ b/frontend/src/app/api.ts @@ -27,40 +27,74 @@ const rawBaseQuery = fetchBaseQuery({ * Wrapper that intercepts 401 responses and attempts a silent token refresh. * If the refresh succeeds, the original request is retried with the new token. * If the refresh fails, credentials are cleared (user sees login screen). + * + * Refresh is single-flighted: if multiple requests 401 simultaneously (typical + * on tab wake / network resume), or multiple call sites trigger + * POST /auth/refresh in parallel (RTK Query 401 handler + scheduled refresh + * + WS reconnect + audio recovery), only one network refresh actually goes + * out and the rest await the same promise. Without this, parallel refresh + * attempts present the same single-use refresh token; the server detects + * "replay" on the loser and revokes the entire token family — forcing + * re-login even though the refresh cookie is nowhere near its TTL. */ +type RefreshQueryResult = Awaited>; +let refreshInFlight: Promise | null = null; + +function runRefresh( + storeApi: Parameters[1], + extraOptions: Parameters[2], +): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + const refreshResult = await rawBaseQuery( + { url: "/auth/refresh", method: "POST" }, + storeApi, + extraOptions, + ); + if (refreshResult.data) { + const refreshData = refreshResult.data as RefreshResponse; + storeApi.dispatch({ + type: "auth/setCredentials", + payload: { + token: refreshData.token, + role: refreshData.user.role, + username: refreshData.user.username, + passwordNeedChange: false, + }, + }); + } + return refreshResult; + } finally { + refreshInFlight = null; + } + })(); + } + return refreshInFlight; +} + const baseQueryWithRefresh: BaseQueryFn< string | FetchArgs, unknown, FetchBaseQueryError > = async (args, storeApi, extraOptions) => { - let result = await rawBaseQuery(args, storeApi, extraOptions); + const url = typeof args === "string" ? args : args.url; - if (result.error && result.error.status === 401) { - // Don't try to refresh if the failing request IS the refresh endpoint. - const url = typeof args === "string" ? args : args.url; - if (url === "/auth/refresh") { + // Coalesce direct calls to /auth/refresh (useAuthInit, useTokenRefresh, + // WS reauth) onto the same in-flight promise as 401-triggered refreshes. + if (url === "/auth/refresh") { + const result = await runRefresh(storeApi, extraOptions); + if (result.error && result.error.status === 401) { storeApi.dispatch({ type: "auth/clearCredentials" }); - return result; } + return result; + } - // Attempt silent refresh. - const refreshResult = await rawBaseQuery( - { url: "/auth/refresh", method: "POST" }, - storeApi, - extraOptions, - ); + let result = await rawBaseQuery(args, storeApi, extraOptions); + if (result.error && result.error.status === 401) { + const refreshResult = await runRefresh(storeApi, extraOptions); if (refreshResult.data) { - const refreshData = refreshResult.data as RefreshResponse; - storeApi.dispatch({ - type: "auth/setCredentials", - payload: { - token: refreshData.token, - role: refreshData.user.role, - username: refreshData.user.username, - passwordNeedChange: false, - }, - }); // Retry original request with new token. result = await rawBaseQuery(args, storeApi, extraOptions); } else { From 779a550e7aa1ca0551d71a7853dacdae59f96739 Mon Sep 17 00:00:00 2001 From: Randy Hammond Date: Wed, 29 Apr 2026 00:59:06 +0000 Subject: [PATCH 3/3] chore(release): cut v1.3.1 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fe34e..c53eb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.1] — 2026-04-29 + ### Fixed - Idle browser tabs no longer get logged out when the access JWT expires. The frontend now single-flights `POST /api/v1/auth/refresh` so simultaneous 401s on tab wake (config, calls list, listener TG selection, WebSocket reauth, scheduled refresh, etc.) coalesce onto one network request. Previously, parallel refresh attempts presented the same single-use refresh token; the server treated the second attempt as a replay and revoked the entire token family, forcing a re-login despite the 30-day refresh cookie.