Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a5e7aed
fix(auth): acknowledge QR login without waiting for scan
Namnarak Nov 10, 2026
3d00861
fix(auth): display asynchronous QR login errors
Namnarak Nov 10, 2026
5046c1d
chore(protocol): declare asynchronous auth error event
Namnarak Nov 10, 2026
4d885bf
fix(auth): prevent overlapping stale QR login attempts
Namnarak Nov 10, 2026
aab5ff2
fix(auth): serialize token restore with active login
Namnarak Nov 10, 2026
e891471
refactor(auth): use explicit login attempt guard
Namnarak Nov 10, 2026
27e9ac8
test(auth): add shared login attempt guard
Namnarak Nov 10, 2026
e854f66
test(auth): cover login attempt serialization
Namnarak Nov 10, 2026
3650ab5
fix(auth): always release QR guard on sync failure
Namnarak Nov 10, 2026
ccb9991
fix(auth): coordinate logout with auth finalization
Namnarak Nov 10, 2026
11baa71
test(auth): cover finalization barrier
Namnarak Nov 10, 2026
ffa6596
fix(auth): prevent stale finalization after logout
Namnarak Nov 10, 2026
ad53ace
fix(auth): keep logout independent of profile validation
Namnarak Nov 10, 2026
18aaa17
fix(auth): suppress stale login success after logout
Namnarak Nov 10, 2026
b17ddc4
fix(auth): wait for all active finalization tasks
Namnarak Nov 10, 2026
526e74e
test(auth): cover concurrent finalization barriers
Namnarak Nov 10, 2026
17a927d
test(auth): cover concurrent finalization barriers
Namnarak Nov 10, 2026
6b8ddbd
test(auth): stabilize concurrent barrier regression
Namnarak Nov 10, 2026
5bf0bca
test: include nested engine auth suites in CI
Namnarak Nov 10, 2026
51f68c7
Isolate auth attempts from durable session storage
Namnarak Nov 10, 2026
9dcb198
Route all auth flows through isolated storage
Namnarak Nov 10, 2026
fcb4b31
Test logout against late auth storage writes
Namnarak Nov 10, 2026
7bf95af
Skip auth attempts invalidated during storage setup
Namnarak Nov 10, 2026
6bea2d1
Stabilize late auth storage race test
Namnarak Nov 10, 2026
e82aed0
test(auth): fix logout finalization race test ordering
Namnarak Nov 10, 2026
ee4eb1c
fix(auth): isolate E2EE key import until finalization
Namnarak Nov 10, 2026
d20b87d
ci: run LINEJS verification for every pull request
Namnarak Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .github/workflows/linejs-auto-update.yml
Original file line number Diff line number Diff line change
@@ -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 * * *'
Expand Down
132 changes: 132 additions & 0 deletions engine/auth/auth-finalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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<void>((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();
});

test("waits for every concurrently tracked finalization", async () => {
const barrier = createAuthFinalizationBarrier();
let settleFirst!: () => void;
let settleSecond!: () => void;
const first = barrier.track(() => new Promise<void>((resolve) => {
settleFirst = resolve;
}));
const second = barrier.track(() => new Promise<void>((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<void>((resolve) => {
settleFirst = resolve;
}));
let waited = false;
const waiting = barrier.wait().then(() => { waited = true; });

await Promise.resolve();
const second = barrier.track(() => new Promise<void>((resolve) => {
settleSecond = resolve;
}));
await Promise.resolve();
settleFirst();
await first;
await Promise.resolve();
expect(waited).toBe(false);

settleSecond();
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<string, string>();
let generation = 0;
let finishWrite!: () => void;

const finalization = barrier.track(async () => {
if (generation !== 0) return;
await new Promise<void>((resolve) => { finishWrite = resolve; });
// The write was already inside the barrier when Logout invalidated it.
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(() => {
durable.clear();
logoutFinished = true;
});

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<string, string>();
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);
});
});
28 changes: 28 additions & 0 deletions engine/auth/auth-finalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* 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() {
const pending = new Set<Promise<void>>();

return {
track<T>(operation: () => Promise<T>): Promise<T> {
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.add(settled);
void settled.then(() => pending.delete(settled));
return task;
},

async wait(): Promise<void> {
// 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]);
}
},
};
}
46 changes: 46 additions & 0 deletions engine/auth/login-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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");
});
});
27 changes: 27 additions & 0 deletions engine/auth/login-guard.ts
Original file line number Diff line number Diff line change
@@ -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;
};
},
};
}
Loading
Loading