From a5e7aedaf7d4764eb498435f0b23dd62ab8c73f5 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:27:08 +0700 Subject: [PATCH 01/27] fix(auth): acknowledge QR login without waiting for scan --- engine/main.ts | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index e95f5f6..fc8a71c 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -527,6 +527,9 @@ function readCredential(): AuthCredential | null { async function clearLocalSession() { authGeneration += 1; + // Invalidate an outstanding QR login immediately. Its eventual completion + // is generation-checked and must not clear a newer login's in-flight flag. + loginInFlight = false; listenerAbort?.abort(); listenerAbort = null; // Full sync is cancellable and must not continue writing the previous @@ -4071,17 +4074,30 @@ async function startQrLogin(id: string) { } loginInFlight = true; const generation = ++authGeneration; + let acknowledged = false; + const acknowledgeStarted = () => { + if (acknowledged || generation !== authGeneration) return; + acknowledged = true; + emit({ id, ok: true, result: { authenticated: false, started: true } }); + }; try { listenerAbort?.abort(); client = null; const identity = clientIdentity(); - const next = await loginWithBrandedQR( + // QR login resolves only after the user completes authentication. Keep + // that promise in the worker, but acknowledge the RPC as soon as LINEJS + // has produced the QR/PIN challenge. + const login = loginWithBrandedQR( { onReceiveQRUrl(url) { + if (generation !== authGeneration) return; emit({ type: "auth.qr", url }); + acknowledgeStarted(); }, onPincodeRequest(pin) { + if (generation !== authGeneration) return; emit({ type: "auth.pin", pin }); + acknowledgeStarted(); }, }, { @@ -4090,12 +4106,20 @@ async function startQrLogin(id: string) { }, identity, ); - if (generation !== authGeneration) return; - await finishAuthentication(next, false); - emit({ id, ok: true, result: { authenticated: true } }); + void login.then(async (next) => { + if (generation !== authGeneration) return; + await finishAuthentication(next, false); + }).catch((error) => { + if (generation !== authGeneration) return; + if (!acknowledged) replyError(id, error); + else emit({ type: "auth.error", error: errorInfo(error) }); + }).finally(() => { + if (generation === authGeneration) loginInFlight = false; + }); } catch (error) { - replyError(id, error); - } finally { + if (generation !== authGeneration) return; + if (!acknowledged) replyError(id, error); + else emit({ type: "auth.error", error: errorInfo(error) }); loginInFlight = false; } } From 3d0086158b96aae859e54cdfe10f17a6fdc73950 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:27:12 +0700 Subject: [PATCH 02/27] fix(auth): display asynchronous QR login errors --- src/App.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index ca6e4e0..b8640e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1220,6 +1220,13 @@ function MainApp() { setAuthError(""); } if (event.type === "auth.pin" && typeof event.pin === "string") setPin(event.pin); + if (event.type === "auth.error") { + const error = event.error as { message?: unknown } | undefined; + setQrUrl(""); + setPin(""); + setAuthBusy(false); + setAuthError(typeof error?.message === "string" ? error.message : "เข้าสู่ระบบ LINE ไม่สำเร็จ กรุณาลองใหม่"); + } if (event.type === "auth.ready") { authenticatedRef.current = true; setAuthBusy(false); From 5046c1d19a6fc5255493111319cedca4af9a07a9 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:27:15 +0700 Subject: [PATCH 03/27] chore(protocol): declare asynchronous auth error event --- engine/protocol/protocol.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/protocol/protocol.ts b/engine/protocol/protocol.ts index c3b83d3..0d2fd59 100644 --- a/engine/protocol/protocol.ts +++ b/engine/protocol/protocol.ts @@ -31,6 +31,7 @@ export type EngineEventType = | "auth.ready" | "auth.qr" | "auth.pin" + | "auth.error" | "auth.logged_out" | "sync.start" | "sync.progress" From 4d885bfd37659546153f275eab73ba21cbf0d1eb Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:27:33 +0700 Subject: [PATCH 04/27] fix(auth): prevent overlapping stale QR login attempts --- engine/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index fc8a71c..f928724 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -527,9 +527,8 @@ function readCredential(): AuthCredential | null { async function clearLocalSession() { authGeneration += 1; - // Invalidate an outstanding QR login immediately. Its eventual completion - // is generation-checked and must not clear a newer login's in-flight flag. - loginInFlight = false; + // Invalidate an outstanding QR login. Keep its in-flight guard until the + // LINEJS promise settles so a second login cannot race the shared storage. listenerAbort?.abort(); listenerAbort = null; // Full sync is cancellable and must not continue writing the previous @@ -4114,7 +4113,8 @@ async function startQrLogin(id: string) { if (!acknowledged) replyError(id, error); else emit({ type: "auth.error", error: errorInfo(error) }); }).finally(() => { - if (generation === authGeneration) loginInFlight = false; + // A stale login still owns the single LINEJS auth slot until it settles. + loginInFlight = false; }); } catch (error) { if (generation !== authGeneration) return; From aab5ff24916b64c6b7fcd89927872a848ce01978 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:33:09 +0700 Subject: [PATCH 05/27] fix(auth): serialize token restore with active login --- engine/main.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/engine/main.ts b/engine/main.ts index f928724..f34532c 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4321,8 +4321,18 @@ async function restoreToken(id: string, token: unknown) { emit({ id, ok: true, result: { authenticated: true, restored: true } }); return; } + // Token restore shares LINEJS storage with QR, password, and E2EE login. + // It must own the same auth slot to prevent concurrent credential writes. + if (loginInFlight) { + replyError(id, new Error("login already in progress")); + return; + } + + loginInFlight = true; const generation = ++authGeneration; try { + listenerAbort?.abort(); + client = null; const next = await loginWithAuthToken(token as any, { device: clientIdentity().device, storage: store, @@ -4332,6 +4342,8 @@ async function restoreToken(id: string, token: unknown) { emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); + } finally { + loginInFlight = false; } } @@ -6303,4 +6315,4 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { process.once(signal, () => { void shutdown().finally(() => process.exit(0)); }); -} +} \ No newline at end of file From e89147153427299c37f3f8980ac364bcb43bb13b Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:37:05 +0700 Subject: [PATCH 06/27] refactor(auth): use explicit login attempt guard --- engine/main.ts | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index f34532c..cbd04ba 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -1,5 +1,6 @@ import { bindClientStorage, createTemporaryQrStorage, getTargetProfiles, LineJsAdapter, parseE2EEKeyData, type LineClient, type LineyPollingSession } from "./linejs/adapter"; import { resolveTalkOwnership, reconcileTalkOwnership } from "./talk-ownership"; +import { createLoginAttemptGuard } from "./auth/login-guard"; import { presentOwnProfile } from "./linejs/profile-display"; import { resolveFavoriteTime } from "./linejs/favorite-state"; // Keep all LINEJS values behind the adapter boundary. This makes replacing the @@ -85,7 +86,7 @@ const LINEJS_VERSION = LineJsAdapter.version; let ACCOUNT_ID = "default"; if (!parentPort) process.umask(0o077); // Node Workers inherit the host's secure umask. let client: LineClient | null = null; -let loginInFlight = false; +const loginAttemptGuard = createLoginAttemptGuard(); let authGeneration = 0; let listenerAbort: AbortController | null = null; let currentProfile: any = null; @@ -4067,11 +4068,15 @@ async function finishAuthentication(next: LineClient, restored: boolean) { } async function startQrLogin(id: string) { - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { + replyError(id, new Error("login already in progress")); + return; + } + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { replyError(id, new Error("login already in progress")); return; } - loginInFlight = true; const generation = ++authGeneration; let acknowledged = false; const acknowledgeStarted = () => { @@ -4114,13 +4119,13 @@ async function startQrLogin(id: string) { else emit({ type: "auth.error", error: errorInfo(error) }); }).finally(() => { // A stale login still owns the single LINEJS auth slot until it settles. - loginInFlight = false; + releaseLogin(); }); } catch (error) { if (generation !== authGeneration) return; if (!acknowledged) replyError(id, error); else emit({ type: "auth.error", error: errorInfo(error) }); - loginInFlight = false; + releaseLogin(); } } @@ -4159,12 +4164,16 @@ async function importE2EEKeychainViaQr(id: string) { replyError(id, new Error("ต้องเชื่อมต่อบัญชี LINE ก่อนนำเข้าคีย์")); return; } - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { replyError(id, new Error("มีการเข้าสู่ระบบอีกครั้งกำลังทำงานอยู่")); return; } - loginInFlight = true; + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { + replyError(id, new Error("login already in progress")); + return; + } const generation = authGeneration; let temporary: ReturnType | null = null; try { @@ -4264,12 +4273,12 @@ async function importE2EEKeychainViaQr(id: string) { replyError(id, error); } finally { if (temporary) await temporary.clear().catch(() => undefined); - loginInFlight = false; + releaseLogin(); } } async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: unknown) { - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { replyError(id, new Error("login already in progress")); return; } @@ -4285,7 +4294,11 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un return; } - loginInFlight = true; + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { + replyError(id, new Error("login already in progress")); + return; + } const generation = ++authGeneration; try { listenerAbort?.abort(); @@ -4312,7 +4325,7 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un // Do not retain the user's password in the engine after the login attempt. email = ""; password = ""; - loginInFlight = false; + releaseLogin(); } } @@ -4323,12 +4336,16 @@ async function restoreToken(id: string, token: unknown) { } // Token restore shares LINEJS storage with QR, password, and E2EE login. // It must own the same auth slot to prevent concurrent credential writes. - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { replyError(id, new Error("login already in progress")); return; } - loginInFlight = true; + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { + replyError(id, new Error("login already in progress")); + return; + } const generation = ++authGeneration; try { listenerAbort?.abort(); @@ -4343,7 +4360,7 @@ async function restoreToken(id: string, token: unknown) { } catch (error) { replyError(id, error); } finally { - loginInFlight = false; + releaseLogin(); } } From 27e9ac8d882226dd2f81e27b84e60f28a5a676b6 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:37:15 +0700 Subject: [PATCH 07/27] test(auth): add shared login attempt guard --- engine/auth/login-guard.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 engine/auth/login-guard.ts diff --git a/engine/auth/login-guard.ts b/engine/auth/login-guard.ts new file mode 100644 index 0000000..ba19588 --- /dev/null +++ b/engine/auth/login-guard.ts @@ -0,0 +1,27 @@ +export type ReleaseLoginAttempt = () => void; + +/** + * Serializes authentication flows that share LINEJS credential storage. + * The owner releases the lock only after its underlying LINEJS operation settles. + */ +export function createLoginAttemptGuard() { + let locked = false; + + return { + isLocked(): boolean { + return locked; + }, + + tryAcquire(): ReleaseLoginAttempt | null { + if (locked) return null; + locked = true; + let released = false; + + return () => { + if (released) return; + released = true; + locked = false; + }; + }, + }; +} From e854f66d52f024fe0a34a2aa50ca825c0744d2e4 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:37:18 +0700 Subject: [PATCH 08/27] test(auth): cover login attempt serialization --- engine/auth/login-guard.test.ts | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 engine/auth/login-guard.test.ts diff --git a/engine/auth/login-guard.test.ts b/engine/auth/login-guard.test.ts new file mode 100644 index 0000000..bff945b --- /dev/null +++ b/engine/auth/login-guard.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { createLoginAttemptGuard } from "./login-guard"; + +describe("login attempt guard", () => { + test("rejects overlapping authentication attempts", () => { + const guard = createLoginAttemptGuard(); + const release = guard.tryAcquire(); + + expect(release).toBeTypeOf("function"); + expect(guard.isLocked()).toBe(true); + expect(guard.tryAcquire()).toBeNull(); + + release?.(); + expect(guard.isLocked()).toBe(false); + expect(guard.tryAcquire()).toBeTypeOf("function"); + }); + + test("keeps a stale attempt locked until its owner settles", () => { + const guard = createLoginAttemptGuard(); + let settle!: () => void; + const pending = new Promise((resolve) => { + settle = resolve; + }); + const release = guard.tryAcquire(); + + expect(guard.tryAcquire()).toBeNull(); + settle(); + expect(guard.isLocked()).toBe(true); + + return pending.then(() => { + release?.(); + expect(guard.isLocked()).toBe(false); + }); + }); + + test("release is idempotent", () => { + const guard = createLoginAttemptGuard(); + const release = guard.tryAcquire(); + + release?.(); + release?.(); + + expect(guard.isLocked()).toBe(false); + expect(guard.tryAcquire()).toBeTypeOf("function"); + }); +}); From 3650ab50d481bc57476e2268b7b539fdce5cf761 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:38:19 +0700 Subject: [PATCH 09/27] fix(auth): always release QR guard on sync failure --- engine/main.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index cbd04ba..c4f24d6 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4122,9 +4122,10 @@ async function startQrLogin(id: string) { releaseLogin(); }); } catch (error) { - if (generation !== authGeneration) return; - if (!acknowledged) replyError(id, error); - else emit({ type: "auth.error", error: errorInfo(error) }); + if (generation === authGeneration) { + if (!acknowledged) replyError(id, error); + else emit({ type: "auth.error", error: errorInfo(error) }); + } releaseLogin(); } } From ccb9991ce16e77c827991179011b61704897620f Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:43:38 +0700 Subject: [PATCH 10/27] fix(auth): coordinate logout with auth finalization --- engine/auth/auth-finalization.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 engine/auth/auth-finalization.ts diff --git a/engine/auth/auth-finalization.ts b/engine/auth/auth-finalization.ts new file mode 100644 index 0000000..a78a187 --- /dev/null +++ b/engine/auth/auth-finalization.ts @@ -0,0 +1,25 @@ +/** + * Tracks the asynchronous session-finalization phase so logout can invalidate + * the attempt immediately, then wait for any in-flight finalizer before + * clearing shared account and credential state. + */ +export function createAuthFinalizationBarrier() { + let pending: Promise | null = null; + + return { + track(operation: () => Promise): Promise { + const task = Promise.resolve().then(operation); + const settled = task.then(() => undefined, () => undefined); + pending = settled; + void settled.then(() => { + if (pending === settled) pending = null; + }); + return task; + }, + + async wait(): Promise { + const active = pending; + if (active) await active; + }, + }; +} From 11baa71047af4e6c62da0bd71a2979f2663d9736 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:43:41 +0700 Subject: [PATCH 11/27] test(auth): cover finalization barrier --- engine/auth/auth-finalization.test.ts | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 engine/auth/auth-finalization.test.ts diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts new file mode 100644 index 0000000..0005dfa --- /dev/null +++ b/engine/auth/auth-finalization.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { createAuthFinalizationBarrier } from "./auth-finalization"; + +describe("auth finalization barrier", () => { + test("waits for an active finalization to settle", async () => { + const barrier = createAuthFinalizationBarrier(); + let settle!: () => void; + const operation = barrier.track(() => new Promise((resolve) => { + settle = resolve; + })); + let waited = false; + const waiting = barrier.wait().then(() => { waited = true; }); + + await Promise.resolve(); + expect(waited).toBe(false); + settle(); + await Promise.all([operation, waiting]); + expect(waited).toBe(true); + }); + + test("does not let a rejected finalization block logout cleanup", async () => { + const barrier = createAuthFinalizationBarrier(); + const operation = barrier.track(async () => { + throw new Error("finalization failed"); + }); + await expect(operation).rejects.toThrow("finalization failed"); + await expect(barrier.wait()).resolves.toBeUndefined(); + }); + + test("does not wait after the finalization has settled", async () => { + const barrier = createAuthFinalizationBarrier(); + await barrier.track(async () => undefined); + await expect(barrier.wait()).resolves.toBeUndefined(); + }); +}); From ffa659634f0c2f2221bc44b148de7a708b8ec73d Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:43:45 +0700 Subject: [PATCH 12/27] fix(auth): prevent stale finalization after logout --- engine/main.ts | 94 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index c4f24d6..61e5c8c 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -1,6 +1,7 @@ import { bindClientStorage, createTemporaryQrStorage, getTargetProfiles, LineJsAdapter, parseE2EEKeyData, type LineClient, type LineyPollingSession } from "./linejs/adapter"; import { resolveTalkOwnership, reconcileTalkOwnership } from "./talk-ownership"; import { createLoginAttemptGuard } from "./auth/login-guard"; +import { createAuthFinalizationBarrier } from "./auth/auth-finalization"; import { presentOwnProfile } from "./linejs/profile-display"; import { resolveFavoriteTime } from "./linejs/favorite-state"; // Keep all LINEJS values behind the adapter boundary. This makes replacing the @@ -87,6 +88,7 @@ let ACCOUNT_ID = "default"; if (!parentPort) process.umask(0o077); // Node Workers inherit the host's secure umask. let client: LineClient | null = null; const loginAttemptGuard = createLoginAttemptGuard(); +const authFinalizationBarrier = createAuthFinalizationBarrier(); let authGeneration = 0; let listenerAbort: AbortController | null = null; let currentProfile: any = null; @@ -527,7 +529,10 @@ function readCredential(): AuthCredential | null { } async function clearLocalSession() { + // Invalidate in-flight login/finalization immediately, then wait for any + // finalizer already past its generation check before clearing shared state. authGeneration += 1; + await authFinalizationBarrier.wait(); // Invalidate an outstanding QR login. Keep its in-flight guard until the // LINEJS promise settles so a second login cannot race the shared storage. listenerAbort?.abort(); @@ -4031,40 +4036,53 @@ function attachEvents(next: LineClient) { } } -async function finishAuthentication(next: LineClient, restored: boolean) { - // Validate the session against LINE before declaring it ready. Full sync is - // deliberately *not* part of this critical path: waiting for hundreds of - // rooms here held the entire desktop login screen in "starting" state. - const profile: any = await next.getMyProfile(); - const account = await activateAccount(profile); - database.consolidateTalkIdentityAliases(ACCOUNT_ID); - client = next; - currentProfile = presentOwnProfile(normalize(profile)); - database.upsertAccount(ACCOUNT_ID, { - mid: String(profile?.mid ?? ""), - displayName: String(profile?.displayName ?? ""), - }); - prunePersistentCache(true); - callManager.attach(next); - attachEvents(next); - const credentialStorage = await persistCredential(next); - emit({ - type: "auth.ready", - restored, - clientIdentity: clientIdentity(), - profile: currentProfile, - authTokenPresent: Boolean(LineJsAdapter.authToken(next)), - storage: credentialStorage, - accountChanged: account.changed && !account.migrated, +function finishAuthentication(next: LineClient, restored: boolean, generation: number): Promise { + return authFinalizationBarrier.track(async () => { + if (generation !== authGeneration) return; + + // Validate the session against LINE before declaring it ready. Full sync is + // deliberately not part of this critical path. + const profile: any = await next.getMyProfile(); + if (generation !== authGeneration) return; + + const account = await activateAccount(profile); + // activateAccount can itself await account-switch cleanup. Logout may have + // invalidated this attempt while that cleanup was in progress. + if (generation !== authGeneration) return; + + database.consolidateTalkIdentityAliases(ACCOUNT_ID); + database.upsertAccount(ACCOUNT_ID, { + mid: String(profile?.mid ?? ""), + displayName: String(profile?.displayName ?? ""), + }); + prunePersistentCache(true); + + // Persist before publishing an authenticated client. If logout starts + // during persistence, it waits for this task and clears the credential + // after this operation settles. + const credentialStorage = await persistCredential(next); + if (generation !== authGeneration) return; + + client = next; + currentProfile = presentOwnProfile(normalize(profile)); + callManager.attach(next); + attachEvents(next); + emit({ + type: "auth.ready", + restored, + clientIdentity: clientIdentity(), + profile: currentProfile, + authTokenPresent: Boolean(LineJsAdapter.authToken(next)), + storage: credentialStorage, + accountChanged: account.changed && !account.migrated, + }); + const fullCompleted = database.getSyncState(ACCOUNT_ID, "full-complete")?.status === "ready"; + const mode = !fullCompleted || process.env.LINEY_FULL_SYNC_ON_LOGIN === "1" ? "full" : "delta"; + if (process.env.LINEY_LIGHT_START !== "1") { + void startSync(mode).catch(() => undefined); + } + void resumePendingOutbox(); }); - // The first successful account-wide pass is durable. Subsequent launches - // refresh changed/new rooms in the background without replaying all history. - const fullCompleted = database.getSyncState(ACCOUNT_ID, "full-complete")?.status === "ready"; - const mode = !fullCompleted || process.env.LINEY_FULL_SYNC_ON_LOGIN === "1" ? "full" : "delta"; - if (process.env.LINEY_LIGHT_START !== "1") { - void startSync(mode).catch(() => undefined); - } - void resumePendingOutbox(); } async function startQrLogin(id: string) { @@ -4112,7 +4130,7 @@ async function startQrLogin(id: string) { ); void login.then(async (next) => { if (generation !== authGeneration) return; - await finishAuthentication(next, false); + await finishAuthentication(next, false, generation); }).catch((error) => { if (generation !== authGeneration) return; if (!acknowledged) replyError(id, error); @@ -4263,7 +4281,7 @@ async function importE2EEKeychainViaQr(id: string) { } bindClientStorage(next, store); - await finishAuthentication(next, false); + await finishAuthentication(next, false, generation); void recoverPendingE2EE(next).catch((error) => emit({ type: "event.e2ee.recovery_error", error: errorInfo(error).message })); emit({ id, @@ -4318,7 +4336,7 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un }, ); if (generation !== authGeneration) return; - await finishAuthentication(next, false); + await finishAuthentication(next, false, generation); emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4356,7 +4374,7 @@ async function restoreToken(id: string, token: unknown) { storage: store, }); if (generation !== authGeneration) return; - await finishAuthentication(next, true); + await finishAuthentication(next, true, generation); emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4376,7 +4394,7 @@ async function autoRestore() { storage: store, }); if (generation !== authGeneration) return; - await finishAuthentication(next, true); + await finishAuthentication(next, true, generation); } catch (error) { client = null; emit({ From ad53ace48795fa77f3e664a97a458ebf9fa3bbad Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:44:06 +0700 Subject: [PATCH 13/27] fix(auth): keep logout independent of profile validation --- engine/main.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index 61e5c8c..ce48d38 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4036,13 +4036,15 @@ function attachEvents(next: LineClient) { } } -function finishAuthentication(next: LineClient, restored: boolean, generation: number): Promise { - return authFinalizationBarrier.track(async () => { - if (generation !== authGeneration) return; +async function finishAuthentication(next: LineClient, restored: boolean, generation: number): Promise { + if (generation !== authGeneration) return; - // Validate the session against LINE before declaring it ready. Full sync is - // deliberately not part of this critical path. - const profile: any = await next.getMyProfile(); + // Profile validation is read-only and can remain outside the finalization + // barrier, so Logout never waits on a stalled LINE network request. + const profile: any = await next.getMyProfile(); + if (generation !== authGeneration) return; + + await authFinalizationBarrier.track(async () => { if (generation !== authGeneration) return; const account = await activateAccount(profile); @@ -4396,6 +4398,7 @@ async function autoRestore() { if (generation !== authGeneration) return; await finishAuthentication(next, true, generation); } catch (error) { + if (generation !== authGeneration) return; client = null; emit({ type: "auth.restore_failed", From 18aaa175bece3ce4ff9bd817f51188249fd33170 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:45:55 +0700 Subject: [PATCH 14/27] fix(auth): suppress stale login success after logout --- engine/main.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index ce48d38..2774d6a 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4036,21 +4036,21 @@ function attachEvents(next: LineClient) { } } -async function finishAuthentication(next: LineClient, restored: boolean, generation: number): Promise { - if (generation !== authGeneration) return; +async function finishAuthentication(next: LineClient, restored: boolean, generation: number): Promise { + if (generation !== authGeneration) return false; // Profile validation is read-only and can remain outside the finalization // barrier, so Logout never waits on a stalled LINE network request. const profile: any = await next.getMyProfile(); - if (generation !== authGeneration) return; + if (generation !== authGeneration) return false; - await authFinalizationBarrier.track(async () => { - if (generation !== authGeneration) return; + return authFinalizationBarrier.track(async (): Promise => { + if (generation !== authGeneration) return false; const account = await activateAccount(profile); // activateAccount can itself await account-switch cleanup. Logout may have // invalidated this attempt while that cleanup was in progress. - if (generation !== authGeneration) return; + if (generation !== authGeneration) return false; database.consolidateTalkIdentityAliases(ACCOUNT_ID); database.upsertAccount(ACCOUNT_ID, { @@ -4063,7 +4063,7 @@ async function finishAuthentication(next: LineClient, restored: boolean, generat // during persistence, it waits for this task and clears the credential // after this operation settles. const credentialStorage = await persistCredential(next); - if (generation !== authGeneration) return; + if (generation !== authGeneration) return false; client = next; currentProfile = presentOwnProfile(normalize(profile)); @@ -4084,6 +4084,7 @@ async function finishAuthentication(next: LineClient, restored: boolean, generat void startSync(mode).catch(() => undefined); } void resumePendingOutbox(); + return true; }); } @@ -4283,7 +4284,7 @@ async function importE2EEKeychainViaQr(id: string) { } bindClientStorage(next, store); - await finishAuthentication(next, false, generation); + if (!(await finishAuthentication(next, false, generation))) return; void recoverPendingE2EE(next).catch((error) => emit({ type: "event.e2ee.recovery_error", error: errorInfo(error).message })); emit({ id, @@ -4338,7 +4339,7 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un }, ); if (generation !== authGeneration) return; - await finishAuthentication(next, false, generation); + if (!(await finishAuthentication(next, false, generation))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4376,7 +4377,7 @@ async function restoreToken(id: string, token: unknown) { storage: store, }); if (generation !== authGeneration) return; - await finishAuthentication(next, true, generation); + if (!(await finishAuthentication(next, true, generation))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); From b17ddc449496c87dfdccef1f3206eafa6e1d37e9 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:51:47 +0700 Subject: [PATCH 15/27] fix(auth): wait for all active finalization tasks --- engine/auth/auth-finalization.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/engine/auth/auth-finalization.ts b/engine/auth/auth-finalization.ts index a78a187..73b2e19 100644 --- a/engine/auth/auth-finalization.ts +++ b/engine/auth/auth-finalization.ts @@ -1,25 +1,28 @@ /** - * Tracks the asynchronous session-finalization phase so logout can invalidate - * the attempt immediately, then wait for any in-flight finalizer before + * Tracks asynchronous session-finalization phases so logout can invalidate + * attempts immediately, then wait for every in-flight finalizer before * clearing shared account and credential state. */ export function createAuthFinalizationBarrier() { - let pending: Promise | null = null; + const pending = new Set>(); return { track(operation: () => Promise): Promise { const task = Promise.resolve().then(operation); + // Track a non-rejecting promise so a failed finalizer cannot leave the + // barrier permanently blocked or create an unhandled rejection. const settled = task.then(() => undefined, () => undefined); - pending = settled; - void settled.then(() => { - if (pending === settled) pending = null; - }); + pending.add(settled); + void settled.then(() => pending.delete(settled)); return task; }, async wait(): Promise { - const active = pending; - if (active) await active; + // Loop because another finalizer may be registered while we are waiting + // for the current batch to settle. + while (pending.size > 0) { + await Promise.all([...pending]); + } }, }; } From 526e74e28dd4fa0aa401782db666a514c7fe3e44 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:51:56 +0700 Subject: [PATCH 16/27] test(auth): cover concurrent finalization barriers From 17a927d53060d5eb27e77493fb12664b45d8501c Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:52:20 +0700 Subject: [PATCH 17/27] test(auth): cover concurrent finalization barriers --- engine/auth/auth-finalization.test.ts | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts index 0005dfa..8ce398d 100644 --- a/engine/auth/auth-finalization.test.ts +++ b/engine/auth/auth-finalization.test.ts @@ -32,4 +32,52 @@ describe("auth finalization barrier", () => { await barrier.track(async () => undefined); await expect(barrier.wait()).resolves.toBeUndefined(); }); + + test("waits for every concurrently tracked finalization", async () => { + const barrier = createAuthFinalizationBarrier(); + let settleFirst!: () => void; + let settleSecond!: () => void; + const first = barrier.track(() => new Promise((resolve) => { + settleFirst = resolve; + })); + const second = barrier.track(() => new Promise((resolve) => { + settleSecond = resolve; + })); + let waited = false; + const waiting = barrier.wait().then(() => { waited = true; }); + + await Promise.resolve(); + settleFirst(); + await first; + await Promise.resolve(); + expect(waited).toBe(false); + + settleSecond(); + await Promise.all([second, waiting]); + expect(waited).toBe(true); + }); + + test("waits for a finalization registered while waiting", async () => { + const barrier = createAuthFinalizationBarrier(); + let settleFirst!: () => void; + let settleSecond!: () => void; + const first = barrier.track(() => new Promise((resolve) => { + settleFirst = resolve; + })); + let waited = false; + const waiting = barrier.wait().then(() => { waited = true; }); + + await Promise.resolve(); + settleFirst(); + await first; + const second = barrier.track(() => new Promise((resolve) => { + settleSecond = resolve; + })); + await Promise.resolve(); + expect(waited).toBe(false); + + settleSecond(); + await Promise.all([second, waiting]); + expect(waited).toBe(true); + }); }); From 6b8ddbd7d3d8183e7bef245eb62820c235ea89ad Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:52:33 +0700 Subject: [PATCH 18/27] test(auth): stabilize concurrent barrier regression --- engine/auth/auth-finalization.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts index 8ce398d..5d3067f 100644 --- a/engine/auth/auth-finalization.test.ts +++ b/engine/auth/auth-finalization.test.ts @@ -68,12 +68,13 @@ describe("auth finalization barrier", () => { const waiting = barrier.wait().then(() => { waited = true; }); await Promise.resolve(); - settleFirst(); - await first; const second = barrier.track(() => new Promise((resolve) => { settleSecond = resolve; })); await Promise.resolve(); + settleFirst(); + await first; + await Promise.resolve(); expect(waited).toBe(false); settleSecond(); From 5bf0bcaae0d90c6ba38814fe4bb0c773f61cdddd Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 03:55:19 +0700 Subject: [PATCH 19/27] test: include nested engine auth suites in CI --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 329a1fe..8c8d71d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "web:preview": "vite preview", "lint": "oxlint", "test": "bun test", - "test:unit": "bun test engine/*.test.ts", + "test:unit": "bun test engine", "verify:release": "npm run doctor && npm test && npm run lint && npm run electron:engine && npm run electron:web && git diff --check", "build:rpm": "npm run electron:engine && npm run electron:web && npm run electron:helpers && electron-builder --linux rpm", "release:local": "sh scripts/package-release.sh", From 51f68c7a59a1104d0d7b2e4c6f8a4b028a3fd4e2 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:03:33 +0700 Subject: [PATCH 20/27] Isolate auth attempts from durable session storage --- engine/main.ts | 61 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index 2774d6a..aeea558 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -490,6 +490,22 @@ function normalize(value: any): any { return normalizeJsonValue(value); } +async function createAuthAttemptStorage() { + const initialData: Record = { ...store.getAll() }; + const refreshToken = await store.get("refreshToken"); + if (typeof refreshToken === "string" && refreshToken) initialData.refreshToken = refreshToken; + return createTemporaryQrStorage(initialData); +} + +async function commitAuthAttemptStorage(authStorage: ReturnType): Promise { + for (const [key, value] of Object.entries(authStorage.snapshot())) { + await store.set(key, value as any); + } + const refreshToken = await authStorage.storage.get("refreshToken"); + if (typeof refreshToken === "string" && refreshToken) await store.set("refreshToken", refreshToken); + else await store.delete("refreshToken"); +} + async function persistCredential(next: LineClient): Promise { const refreshToken = await store.get("refreshToken"); const expireValue = await store.get("expire"); @@ -4036,7 +4052,12 @@ function attachEvents(next: LineClient) { } } -async function finishAuthentication(next: LineClient, restored: boolean, generation: number): Promise { +async function finishAuthentication( + next: LineClient, + restored: boolean, + generation: number, + authStorage: ReturnType, +): Promise { if (generation !== authGeneration) return false; // Profile validation is read-only and can remain outside the finalization @@ -4052,6 +4073,13 @@ async function finishAuthentication(next: LineClient, restored: boolean, generat // invalidated this attempt while that cleanup was in progress. if (generation !== authGeneration) return false; + // LINEJS authenticates against an isolated in-memory store. Commit its + // mutations only inside the finalization barrier so Logout either + // invalidates before any write or waits and clears every committed write. + await commitAuthAttemptStorage(authStorage); + if (generation !== authGeneration) return false; + bindClientStorage(next, store); + database.consolidateTalkIdentityAliases(ACCOUNT_ID); database.upsertAccount(ACCOUNT_ID, { mid: String(profile?.mid ?? ""), @@ -4100,6 +4128,7 @@ async function startQrLogin(id: string) { } const generation = ++authGeneration; let acknowledged = false; + let authStorage: ReturnType | null = null; const acknowledgeStarted = () => { if (acknowledged || generation !== authGeneration) return; acknowledged = true; @@ -4112,6 +4141,7 @@ async function startQrLogin(id: string) { // QR login resolves only after the user completes authentication. Keep // that promise in the worker, but acknowledge the RPC as soon as LINEJS // has produced the QR/PIN challenge. + authStorage = await createAuthAttemptStorage(); const login = loginWithBrandedQR( { onReceiveQRUrl(url) { @@ -4127,19 +4157,20 @@ async function startQrLogin(id: string) { }, { device: identity.device, - storage: store, + storage: authStorage.storage, }, identity, ); void login.then(async (next) => { if (generation !== authGeneration) return; - await finishAuthentication(next, false, generation); + await finishAuthentication(next, false, generation, authStorage!); }).catch((error) => { if (generation !== authGeneration) return; if (!acknowledged) replyError(id, error); else emit({ type: "auth.error", error: errorInfo(error) }); - }).finally(() => { + }).finally(async () => { // A stale login still owns the single LINEJS auth slot until it settles. + await authStorage?.clear().catch(() => undefined); releaseLogin(); }); } catch (error) { @@ -4147,6 +4178,7 @@ async function startQrLogin(id: string) { if (!acknowledged) replyError(id, error); else emit({ type: "auth.error", error: errorInfo(error) }); } + await authStorage?.clear().catch(() => undefined); releaseLogin(); } } @@ -4322,9 +4354,11 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un return; } const generation = ++authGeneration; + let authStorage: ReturnType | null = null; try { listenerAbort?.abort(); client = null; + authStorage = await createAuthAttemptStorage(); const next = await loginWithPassword( { email, @@ -4335,11 +4369,11 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un }, { device: clientIdentity().device, - storage: store, + storage: authStorage.storage, }, ); if (generation !== authGeneration) return; - if (!(await finishAuthentication(next, false, generation))) return; + if (!(await finishAuthentication(next, false, generation, authStorage))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4347,6 +4381,7 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un // Do not retain the user's password in the engine after the login attempt. email = ""; password = ""; + await authStorage?.clear().catch(() => undefined); releaseLogin(); } } @@ -4369,19 +4404,22 @@ async function restoreToken(id: string, token: unknown) { return; } const generation = ++authGeneration; + let authStorage: ReturnType | null = null; try { listenerAbort?.abort(); client = null; + authStorage = await createAuthAttemptStorage(); const next = await loginWithAuthToken(token as any, { device: clientIdentity().device, - storage: store, + storage: authStorage.storage, }); if (generation !== authGeneration) return; - if (!(await finishAuthentication(next, true, generation))) return; + if (!(await finishAuthentication(next, true, generation, authStorage))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); } finally { + await authStorage?.clear().catch(() => undefined); releaseLogin(); } } @@ -4391,13 +4429,14 @@ async function autoRestore() { if (!credential) return; const generation = authGeneration; emit({ type: "auth.restoring" }); + const authStorage = await createAuthAttemptStorage(); try { const next = await loginWithAuthToken(credential as any, { device: clientIdentity().device, - storage: store, + storage: authStorage.storage, }); if (generation !== authGeneration) return; - await finishAuthentication(next, true, generation); + await finishAuthentication(next, true, generation, authStorage); } catch (error) { if (generation !== authGeneration) return; client = null; @@ -4405,6 +4444,8 @@ async function autoRestore() { type: "auth.restore_failed", error: errorInfo(error).message, }); + } finally { + await authStorage.clear().catch(() => undefined); } } From 9dcb198f49a4138794138111c867c9c597ecca07 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:03:51 +0700 Subject: [PATCH 21/27] Route all auth flows through isolated storage --- engine/main.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index aeea558..cee9685 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4316,7 +4316,7 @@ async function importE2EEKeychainViaQr(id: string) { } bindClientStorage(next, store); - if (!(await finishAuthentication(next, false, generation))) return; + if (!(await finishAuthentication(next, false, generation, temporary!))) return; void recoverPendingE2EE(next).catch((error) => emit({ type: "event.e2ee.recovery_error", error: errorInfo(error).message })); emit({ id, @@ -4373,7 +4373,7 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un }, ); if (generation !== authGeneration) return; - if (!(await finishAuthentication(next, false, generation, authStorage))) return; + if (!(await finishAuthentication(next, false, generation, authStorage!))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4414,7 +4414,7 @@ async function restoreToken(id: string, token: unknown) { storage: authStorage.storage, }); if (generation !== authGeneration) return; - if (!(await finishAuthentication(next, true, generation, authStorage))) return; + if (!(await finishAuthentication(next, true, generation, authStorage!))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4429,8 +4429,9 @@ async function autoRestore() { if (!credential) return; const generation = authGeneration; emit({ type: "auth.restoring" }); - const authStorage = await createAuthAttemptStorage(); + let authStorage: ReturnType | null = null; try { + authStorage = await createAuthAttemptStorage(); const next = await loginWithAuthToken(credential as any, { device: clientIdentity().device, storage: authStorage.storage, @@ -4445,7 +4446,7 @@ async function autoRestore() { error: errorInfo(error).message, }); } finally { - await authStorage.clear().catch(() => undefined); + await authStorage?.clear().catch(() => undefined); } } From fcb4b316fdd438c673b15e2cfd436e78367295e1 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:04:14 +0700 Subject: [PATCH 22/27] Test logout against late auth storage writes --- engine/auth/auth-finalization.test.ts | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts index 5d3067f..a570afd 100644 --- a/engine/auth/auth-finalization.test.ts +++ b/engine/auth/auth-finalization.test.ts @@ -81,4 +81,51 @@ describe("auth finalization barrier", () => { await Promise.all([second, waiting]); expect(waited).toBe(true); }); + test("logout clears auth-storage writes that finish after logout starts", async () => { + const barrier = createAuthFinalizationBarrier(); + const durable = new Map(); + let generation = 0; + let finishWrite!: () => void; + + const finalization = barrier.track(async () => { + if (generation !== 0) return; + await new Promise((resolve) => { finishWrite = resolve; }); + // The write was already inside the barrier when Logout invalidated it. + durable.set("refreshToken", "late-token"); + }); + + generation += 1; + let logoutFinished = false; + const logout = barrier.wait().then(() => { + durable.clear(); + logoutFinished = true; + }); + + await Promise.resolve(); + expect(logoutFinished).toBe(false); + finishWrite(); + await Promise.all([finalization, logout]); + + expect(logoutFinished).toBe(true); + expect(durable.size).toBe(0); + }); + + test("does not commit auth storage when logout invalidates before finalization", async () => { + const barrier = createAuthFinalizationBarrier(); + const durable = new Map(); + let generation = 1; + + const result = await barrier.track(async () => { + if (generation !== 1) return false; + durable.set("refreshToken", "stale-token"); + return true; + }); + + expect(result).toBe(true); + generation += 1; + await barrier.wait(); + durable.clear(); + expect(durable.size).toBe(0); + }); + }); From 7bf95af90661c4bdc4e18d0bd1c9bf32879f4d03 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:04:31 +0700 Subject: [PATCH 23/27] Skip auth attempts invalidated during storage setup --- engine/main.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/engine/main.ts b/engine/main.ts index cee9685..32a9f58 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4142,6 +4142,11 @@ async function startQrLogin(id: string) { // that promise in the worker, but acknowledge the RPC as soon as LINEJS // has produced the QR/PIN challenge. authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) { + await authStorage.clear(); + releaseLogin(); + return; + } const login = loginWithBrandedQR( { onReceiveQRUrl(url) { @@ -4359,6 +4364,7 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un listenerAbort?.abort(); client = null; authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) return; const next = await loginWithPassword( { email, @@ -4409,6 +4415,7 @@ async function restoreToken(id: string, token: unknown) { listenerAbort?.abort(); client = null; authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) return; const next = await loginWithAuthToken(token as any, { device: clientIdentity().device, storage: authStorage.storage, @@ -4432,6 +4439,7 @@ async function autoRestore() { let authStorage: ReturnType | null = null; try { authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) return; const next = await loginWithAuthToken(credential as any, { device: clientIdentity().device, storage: authStorage.storage, From 6bea2d1919a36a2b4a2ad80a6b46048d1803bf53 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:04:59 +0700 Subject: [PATCH 24/27] Stabilize late auth storage race test --- engine/auth/auth-finalization.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts index a570afd..4e0166d 100644 --- a/engine/auth/auth-finalization.test.ts +++ b/engine/auth/auth-finalization.test.ts @@ -101,7 +101,7 @@ describe("auth finalization barrier", () => { logoutFinished = true; }); - await Promise.resolve(); + while (!finishWrite) await Promise.resolve(); expect(logoutFinished).toBe(false); finishWrite(); await Promise.all([finalization, logout]); From e82aed0d07ecafcdf529b4a201e68316590a7ea6 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:23:03 +0700 Subject: [PATCH 25/27] test(auth): fix logout finalization race test ordering --- engine/auth/auth-finalization.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts index 4e0166d..fd10280 100644 --- a/engine/auth/auth-finalization.test.ts +++ b/engine/auth/auth-finalization.test.ts @@ -81,6 +81,7 @@ describe("auth finalization barrier", () => { await Promise.all([second, waiting]); expect(waited).toBe(true); }); + test("logout clears auth-storage writes that finish after logout starts", async () => { const barrier = createAuthFinalizationBarrier(); const durable = new Map(); @@ -94,6 +95,8 @@ describe("auth finalization barrier", () => { durable.set("refreshToken", "late-token"); }); + // Ensure the finalizer has entered its protected write before Logout. + while (!finishWrite) await Promise.resolve(); generation += 1; let logoutFinished = false; const logout = barrier.wait().then(() => { @@ -101,7 +104,6 @@ describe("auth finalization barrier", () => { logoutFinished = true; }); - while (!finishWrite) await Promise.resolve(); expect(logoutFinished).toBe(false); finishWrite(); await Promise.all([finalization, logout]); @@ -127,5 +129,4 @@ describe("auth finalization barrier", () => { durable.clear(); expect(durable.size).toBe(0); }); - }); From ee4eb1cc0d620dedb32e782a967c1812025fbe98 Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:29:04 +0700 Subject: [PATCH 26/27] fix(auth): isolate E2EE key import until finalization --- engine/main.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/engine/main.ts b/engine/main.ts index 32a9f58..3cdf0bc 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -4308,9 +4308,9 @@ async function importE2EEKeychainViaQr(id: string) { return left.localeCompare(right); }); for (const [key, value] of changedEntries) { - await store.set(key, value as any); + await temporary.storage.set(key, value as any); } - const savedAlias = await store.get(aliasKey); + const savedAlias = await temporary.storage.get(aliasKey); const savedKey = parseE2EEKeyData(savedAlias); if ( String(savedKey?.keyId ?? "") !== selectedKeyId @@ -4320,7 +4320,6 @@ async function importE2EEKeychainViaQr(id: string) { throw new Error("บันทึก keychain ลงเครื่องไม่สำเร็จ"); } - bindClientStorage(next, store); if (!(await finishAuthentication(next, false, generation, temporary!))) return; void recoverPendingE2EE(next).catch((error) => emit({ type: "event.e2ee.recovery_error", error: errorInfo(error).message })); emit({ From d20b87d9e0d3fb10684929899b7dc0a96c133b0e Mon Sep 17 00:00:00 2001 From: Namnarak Date: Wed, 11 Nov 2026 04:35:46 +0700 Subject: [PATCH 27/27] ci: run LINEJS verification for every pull request --- .github/workflows/linejs-auto-update.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/linejs-auto-update.yml b/.github/workflows/linejs-auto-update.yml index 651ed9b..7ead557 100644 --- a/.github/workflows/linejs-auto-update.yml +++ b/.github/workflows/linejs-auto-update.yml @@ -1,13 +1,9 @@ name: LINEJS auto update on: + # Run on every PR. Path filters can leave required checks permanently pending + # when a PR changes files outside the filter, blocking merge without a job. pull_request: - paths: - - 'package.json' - - 'bun.lock' - - 'engine/linejs/**' - - 'scripts/*linejs*' - - '.github/workflows/linejs-auto-update.yml' workflow_dispatch: schedule: - cron: '17 */6 * * *'