Lock inactive dashboard sessions without losing drafts #464

Merged
timmy merged 1 commits from timmy/463-idle-session-lock into main 2026-08-10 07:47:08 +00:00
12 changed files with 402 additions and 27 deletions

View File

@ -133,6 +133,9 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
# Background polling and queued delivery do not. Defaults to 15 minutes.
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
@ -166,8 +169,9 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
open **Active devices** to review creation/expiry times, identify the current device,
and revoke one remote session without interrupting other trusted devices. The API
exposes only independent management IDs and bounded labels—never cookie values,
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
migrated in place and their live sessions remain valid.
session hashes, CSRF proofs, or source addresses. The registry also stores each
session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require the operator access token again.
@ -177,7 +181,12 @@ first use. Expired, replayed, cross-session, and target-substituted grants fail
Gitea or session state is changed. The browser preserves the pending request and
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
If an active session expires, the first authenticated API rejection replaces the
After 15 minutes without pointer, keyboard, or touch activity (configurable through
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
polling or background delivery continued. The dashboard locks and background outbox
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
the device; signing in resumes the existing account-bound work. If an active session
expires, the first authenticated API rejection replaces the
dashboard with sign-in and explains that private drafts remain on the device; signing
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
state. A selectively or globally revoked device instead receives a bounded revocation

View File

@ -35,6 +35,10 @@
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';

View File

@ -115,6 +115,11 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function notifyIdleSession() {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
@ -177,6 +182,9 @@ async function fetchJson(url, options = {}) {
if (response.status === 401 && code === 'session_revoked') {
await purgeRevokedSessionData();
}
if (response.status === 401 && code === 'session_idle') {
await notifyIdleSession();
}
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
error.code = code;

View File

@ -15,6 +15,7 @@
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
location: root.location,
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
confirmAction: message => root.confirm(message),
promptAuthorization: details => root.prompt(
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
@ -27,6 +28,7 @@
});
root.fetch = boundary.fetch;
const attach = () => {
boundary.startActivityHeartbeat();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
@ -91,16 +93,19 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
activityHeartbeatIntervalMs = 60000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null;
async function handleRemoteRevocation() {
if (expirationStarted) return;
@ -117,10 +122,19 @@
location.replace(base + 'login?reason=session-expired');
}
function handleSessionIdle() {
if (expirationStarted) return;
expirationStarted = true;
location.replace(base + 'login?reason=session-idle');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-idle') {
handleSessionIdle();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
@ -253,6 +267,9 @@
if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else if (payload.code === 'session_idle') {
expirationStarted = false;
handleSessionIdle();
} else {
expirationStarted = false;
await handleSessionExpiry();
@ -261,6 +278,27 @@
return response;
}
function recordActivity() {
const current = now();
if (
expirationStarted
|| activityHeartbeat
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
) return Promise.resolve(false);
lastActivityHeartbeatAt = current;
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
.then(response => response.ok)
.catch(() => false)
.finally(() => { activityHeartbeat = null; });
return activityHeartbeat;
}
function startActivityHeartbeat() {
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
addActivityListener?.(type, recordActivity, { passive: true });
});
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
@ -391,5 +429,7 @@
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
recordActivity,
startActivityHeartbeat,
};
});

View File

@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
@ -96,6 +97,18 @@ def _session_store(now: int | None = None) -> SessionStore:
return SessionStore(database, clock=current)
def idle_timeout_seconds() -> int:
return max(
1,
int(
os.getenv(
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
)
),
)
def issue_session(
now: int | None = None, *, device_label: str = "This device"
) -> tuple[str, Session]:
@ -146,7 +159,14 @@ def verify_session_with_reason(
or not session.csrf
):
return SessionVerification(None)
if not _session_store(now).is_active(session.session_id, session.expires_at):
status = _session_store(now).status(
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
if status == "idle":
return SessionVerification(None, "session_idle")
if status != "active":
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
@ -167,6 +187,15 @@ async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def revoke_managed_session(management_id: str) -> bool:
return await asyncio.to_thread(_session_store().revoke_managed, management_id)

View File

@ -670,18 +670,19 @@ async def require_operator_session(request: Request, call_next):
if not public and session is None:
if path.startswith("/api/"):
payload = {"detail": "Authentication required"}
if session_reason == "session_revoked":
if session_reason in {"session_revoked", "session_idle"}:
payload["code"] = session_reason
return JSONResponse(
payload,
status_code=401,
headers={"Cache-Control": "no-store"},
)
login_redirect = (
"login?reason=session-revoked"
if session_reason == "session_revoked"
else _share_target_login_redirect(request)
)
if session_reason == "session_revoked":
login_redirect = "login?reason=session-revoked"
elif session_reason == "session_idle":
login_redirect = "login?reason=session-idle"
else:
login_redirect = _share_target_login_redirect(request)
return RedirectResponse(
login_redirect,
status_code=303,
@ -937,6 +938,29 @@ async def session_status(request: Request):
return payload
@app.post("/api/v1/session/activity")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
try:
active = await dashboard_auth.touch_session(session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if not active:
return JSONResponse(
{"detail": "Authentication required"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return {
"active": True,
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
}
def _today_store() -> TodayStore:
return TodayStore(
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5

View File

@ -58,7 +58,8 @@ class SessionStore:
expires_at INTEGER NOT NULL,
management_id TEXT,
device_label TEXT,
created_at INTEGER
created_at INTEGER,
last_active_at INTEGER
)
"""
)
@ -84,6 +85,7 @@ class SessionStore:
"management_id": "TEXT",
"device_label": "TEXT",
"created_at": "INTEGER",
"last_active_at": "INTEGER",
}
for name, column_type in additions.items():
if name not in columns:
@ -102,6 +104,10 @@ class SessionStore:
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
"ON active_sessions(management_id)"
@ -122,14 +128,15 @@ class SessionStore:
)
connection.execute(
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at"
") VALUES (?, ?, ?, ?, ?)",
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
secrets.token_urlsafe(18),
label,
now,
now,
),
)
except (OSError, sqlite3.Error) as exc:
@ -147,6 +154,55 @@ class SessionStore:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return row is not None and row[0] == expires_at and expires_at > now
def status(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> str:
now = int(self.clock())
query = (
"SELECT expires_at, last_active_at FROM active_sessions "
"WHERE session_hash = ?"
)
parameters = (self._digest(session_id),)
try:
try:
with self._connect() as connection:
row = connection.execute(query, parameters).fetchone()
except sqlite3.OperationalError as exc:
if "no such column: last_active_at" not in str(exc):
raise
with self._connect(initialize=True) as connection:
row = connection.execute(query, parameters).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] != expires_at or expires_at <= now:
return "revoked"
if row[1] + max(1, idle_timeout_seconds) <= now:
return "idle"
return "active"
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE active_sessions SET last_active_at = ? "
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
"AND last_active_at + ? > ?",
(
now,
self._digest(session_id),
expires_at,
now,
max(1, idle_timeout_seconds),
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke(self, session_id: str) -> None:
try:
with self._connect(initialize=True) as connection:

View File

@ -1,4 +1,5 @@
import asyncio
import sqlite3
import time
from urllib.parse import parse_qs, urlsplit
@ -245,10 +246,14 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
lookups = 0
class CountingStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
nonlocal lookups
lookups += 1
return original_store.is_active(session_id, expires_at)
return original_store.status(
session_id,
expires_at,
idle_timeout_seconds=idle_timeout_seconds,
)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
response = await client.get("/api/v1/session")
@ -291,6 +296,52 @@ async def test_anonymous_session_status_discloses_no_offline_lease(access_contro
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
connection.execute(
"UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,)
)
api_response = await client.get("/api/v1/session")
page_response = await client.get("/")
assert api_response.status_code == 401
assert api_response.json() == {
"detail": "Authentication required",
"code": "session_idle",
}
assert page_response.status_code == 303
assert page_response.headers["location"] == "login?reason=session-idle"
@pytest.mark.anyio
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
heartbeat = await client.post(
"/api/v1/session/activity",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert heartbeat.status_code == 200
assert heartbeat.json()["active"] is True
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
@ -615,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
grant = await fresh_grant(client, "revoke_all_sessions", "all")
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
def consume_step_up(self, grant, session_id, *, action, target):
return True
@ -693,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
)
class SlowStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.15)
return True
return "active"
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
@ -847,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
csrf = client.cookies["stackchain_csrf"]
class SlowStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.02)
return True
return "active"
def revoke(self, session_id):
time.sleep(0.15)
@ -893,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
)
class BrokenStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
@ -938,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
@ -966,7 +1017,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
)
class BrokenStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
raise SessionStoreError("public routes must not read the registry")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())

View File

@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@ -57,6 +57,8 @@ const boundary = createSessionBoundary({{
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
now: () => state.now,
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
@ -232,6 +234,29 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
result = run_session_scenario(
"""
boundary.startActivityHeartbeat();
await state.activityListeners.pointerdown();
await state.activityListeners.keydown();
state.now += 60000;
await state.activityListeners.touchstart();
process.stdout.write(JSON.stringify(state));
"""
)
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/session/activity",
"/dashboard/api/v1/session/activity",
]
assert all(request["method"] == "POST" for request in result["requests"])
assert all(
request["headers"]["x-csrf-token"] == "csrf-proof"
for request in result["requests"]
)
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
@ -350,6 +375,24 @@ process.stdout.write(JSON.stringify(state));
assert result["replacedAfterDeletion"] is True
def test_idle_response_locks_without_clearing_private_queued_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["workerMessages"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_revocation_message_clears_window_storage_before_login():
result = run_session_scenario(
"""
@ -363,6 +406,19 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_idle_message_locks_without_clearing_window_storage():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""

View File

@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
assert state["ignored"] == state["expired"]
def test_idle_session_reason_explains_that_saved_work_will_resume():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
}});
controller.showReason('session-idle');
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["status"] == (
"Stackchain locked after inactivity. Your drafts and queued work are still "
"on this device. Sign in to resume."
)
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});

View File

@ -237,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
]
def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}});
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
process.stdout.write(JSON.stringify({state,outcome}));
"""
)
assert result["outcome"] == {"status": 401, "code": "session_idle"}
assert result["state"]["outboxPurges"] == 0
assert result["state"]["deleted"] == []
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-idle"}
]
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""

View File

@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
now[0] = 1_899.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
now[0] = 1_900.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_touch_extends_only_the_matching_live_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
store.activate("laptop-session", 3_000)
now[0] = 1_800.0
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True
now[0] = 2_000.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle"
assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False
def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
now[0] = 1_900.0
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
assert next(device for device in devices if device.current).device_label == "Existing device"
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
)
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
store = SessionStore(database, clock=lambda: 1_000.0)
assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active"
with sqlite3.connect(database) as connection:
last_active_at = connection.execute(
"SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,)
).fetchone()[0]
assert last_active_at == 1_000
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")