Purge private data after remote session revocation #338

Merged
timmy merged 1 commits from timmy/337-purge-private-data-after-remote-revocation into main 2026-08-08 20:18:40 +00:00
13 changed files with 344 additions and 20 deletions

View File

@ -107,10 +107,14 @@ exposes only independent management IDs and bounded labels—never cookie values
session hashes, CSRF proofs, or source addresses. Existing two-column registries are session hashes, CSRF proofs, or source addresses. Existing two-column registries are
migrated in place and their live sessions remain valid. migrated in place and their live sessions remain valid.
If an active session expires or is revoked, the first authenticated API rejection If an active session expires, the first authenticated API rejection replaces the
replaces the dashboard with sign-in and explains that private drafts remain on the dashboard with sign-in and explains that private drafts remain on the device; signing
device; signing in again resumes account-bound queued delivery. This recovery does in again resumes account-bound queued delivery. Expiry recovery does not clear offline
not clear offline state. **Sign out & clear this device** revokes only the current state. A selectively or globally revoked device instead receives a bounded revocation
reason: on its next server contact, Stackchain clears its owned local/session storage,
private outbox database, and dashboard caches before enabling sign-in. Revocation
blocks server access immediately, but no web application can erase a device that
remains offline forever. **Sign out & clear this device** revokes only the current
session before clearing browser state, so a copied cookie cannot be replayed session before clearing browser state, so a copied cookie cannot be replayed
afterward; other signed-in devices remain active. **Sign out all devices** is a afterward; other signed-in devices remain active. **Sign out all devices** is a
separately confirmed lost-device safety action that atomically revokes every existing separately confirmed lost-device safety action that atomically revokes every existing

View File

@ -22,6 +22,7 @@
const button = options.button; const button = options.button;
const fetchImpl = options.fetchImpl; const fetchImpl = options.fetchImpl;
const location = options.location; const location = options.location;
const clearPrivateDeviceData = options.clearPrivateDeviceData;
const continuation = validShareContinuation(options.continuation); const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval; const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
@ -29,9 +30,22 @@
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.'; if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
function showReason(reason) { async function showReason(reason) {
if (reason !== 'session-expired') return; if (reason === 'session-expired') {
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
try {
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
await clearPrivateDeviceData();
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
button.disabled = false;
} catch (_error) {
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this sites data before signing in.';
}
} }
function showRetryCountdown(seconds) { function showRetryCountdown(seconds) {
@ -92,6 +106,7 @@ if (typeof document !== 'undefined') {
fetchImpl: fetch.bind(window), fetchImpl: fetch.bind(window),
location: window.location, location: window.location,
continuation: loginParams.get('continue'), continuation: loginParams.get('continue'),
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
}); });
controller.showReason(loginParams.get('reason')); controller.showReason(loginParams.get('reason'));
form.addEventListener('submit', event => { form.addEventListener('submit', event => {

View File

@ -0,0 +1,61 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.stackchainPrivateDeviceData = factory({
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
});
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
}) {
function removeOwnedStorage(storage) {
if (!storage) return;
const keys = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith('stackchain.')) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
return async function clearPrivateDeviceData() {
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateOutbox();
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
};
});

View File

@ -66,6 +66,16 @@ async function withSessionCsrf(work) {
} }
} }
async function purgeRevokedSessionData() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function fetchJson(url, options = {}) { async function fetchJson(url, options = {}) {
const requestOptions = { ...options }; const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase(); const method = String(options.method || 'GET').toUpperCase();
@ -89,9 +99,13 @@ async function fetchJson(url, options = {}) {
const response = await fetch(new URL(url, self.location.origin), requestOptions); const response = await fetch(new URL(url, self.location.origin), requestOptions);
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const code = payload.code || payload.detail?.code;
if (response.status === 401 && code === 'session_revoked') {
await purgeRevokedSessionData();
}
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.'); const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
error.status = response.status; error.status = response.status;
error.code = payload.detail?.code; error.code = code;
throw error; throw error;
} }
return payload; return payload;

View File

@ -80,6 +80,7 @@
}); });
boundary.resumeQueuedWork(); boundary.resumeQueuedWork();
}; };
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach(); else attach();
root.stackchainSession = boundary; root.stackchainSession = boundary;
@ -92,6 +93,19 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false; let expirationStarted = false;
async function handleRemoteRevocation() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
location.replace(base + 'login?reason=session-revoked');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
}
function csrfToken() { function csrfToken() {
const entry = String(cookie?.() || '').split(';') const entry = String(cookie?.() || '').split(';')
.map(value => value.trim()) .map(value => value.trim())
@ -116,8 +130,14 @@
const response = await fetchImpl(input, requestOptions); const response = await fetchImpl(input, requestOptions);
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
expirationStarted = true; expirationStarted = true;
onExpired(); const payload = await response.clone().json().catch(() => ({}));
location.replace(base + 'login?reason=session-expired'); if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else {
onExpired();
location.replace(base + 'login?reason=session-expired');
}
} }
return response; return response;
} }
@ -230,6 +250,7 @@
listActiveDevices, listActiveDevices,
revokeActiveDevice, revokeActiveDevice,
clearPrivateDeviceData, clearPrivateDeviceData,
handleServiceWorkerMessage,
resumeQueuedWork, resumeQueuedWork,
}; };
}); });

View File

@ -30,6 +30,12 @@ class Session:
expires_at: int expires_at: int
@dataclass(frozen=True)
class SessionVerification:
session: Session | None
reason: str | None = None
def access_token() -> str: def access_token() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "") return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
@ -112,13 +118,15 @@ def issue_session(
return f"{encoded}.{signature}", session return f"{encoded}.{signature}", session
def verify_session(value: str | None, now: int | None = None) -> Session | None: def verify_session_with_reason(
value: str | None, now: int | None = None
) -> SessionVerification:
if not value or "." not in value or not enabled(): if not value or "." not in value or not enabled():
return None return SessionVerification(None)
encoded, supplied_signature = value.rsplit(".", 1) encoded, supplied_signature = value.rsplit(".", 1)
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(supplied_signature, expected): if not hmac.compare_digest(supplied_signature, expected):
return None return SessionVerification(None)
try: try:
payload = json.loads(_decode(encoded)) payload = json.loads(_decode(encoded))
session = Session( session = Session(
@ -127,7 +135,7 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
expires_at=int(payload["exp"]), expires_at=int(payload["exp"]),
) )
except (ValueError, TypeError, KeyError, json.JSONDecodeError): except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return None return SessionVerification(None)
current = int(time.time() if now is None else now) current = int(time.time() if now is None else now)
if ( if (
session.expires_at <= current session.expires_at <= current
@ -136,8 +144,14 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
or not isinstance(session.csrf, str) or not isinstance(session.csrf, str)
or not session.csrf or not session.csrf
): ):
return None return SessionVerification(None)
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None if not _session_store(now).is_active(session.session_id, session.expires_at):
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
def verify_session(value: str | None, now: int | None = None) -> Session | None:
return verify_session_with_reason(value, now).session
def revoke_session(session: Session) -> None: def revoke_session(session: Session) -> None:
@ -160,6 +174,12 @@ async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE)) return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
async def request_session_verification(request: Request) -> SessionVerification:
return await asyncio.to_thread(
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
)
def cookie_path(request: Request) -> str: def cookie_path(request: Request) -> str:
root_path = request.scope.get("root_path", "").rstrip("/") root_path = request.scope.get("root_path", "").rstrip("/")
return root_path or "/" return root_path or "/"

View File

@ -524,9 +524,12 @@ async def require_operator_session(request: Request, call_next):
or (path == "/api/v1/session" and request.method == "POST") or (path == "/api/v1/session" and request.method == "POST")
) )
session = None session = None
session_reason = None
if not public: if not public:
try: try:
session = await dashboard_auth.request_session(request) verification = await dashboard_auth.request_session_verification(request)
session = verification.session
session_reason = verification.reason
except dashboard_auth.SessionStoreError: except dashboard_auth.SessionStoreError:
return JSONResponse( return JSONResponse(
{"detail": "Session registry is temporarily unavailable"}, {"detail": "Session registry is temporarily unavailable"},
@ -535,13 +538,21 @@ async def require_operator_session(request: Request, call_next):
) )
if not public and session is None: if not public and session is None:
if path.startswith("/api/"): if path.startswith("/api/"):
payload = {"detail": "Authentication required"}
if session_reason == "session_revoked":
payload["code"] = session_reason
return JSONResponse( return JSONResponse(
{"detail": "Authentication required"}, payload,
status_code=401, status_code=401,
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
login_redirect = (
"login?reason=session-revoked"
if session_reason == "session_revoked"
else _share_target_login_redirect(request)
)
return RedirectResponse( return RedirectResponse(
_share_target_login_redirect(request), login_redirect,
status_code=303, status_code=303,
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )

View File

@ -14,7 +14,7 @@ LOGIN_HTML = """<!doctype html>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head> <style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p> <body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main> <form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/login.js"></script></body></html>""" <script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
class RevalidatingHTMLResponse(HTMLResponse): class RevalidatingHTMLResponse(HTMLResponse):

View File

@ -295,6 +295,7 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
}, },
) )
phone_status = await phone.get("/api/v1/session") phone_status = await phone.get("/api/v1/session")
phone_page = await phone.get("/", follow_redirects=False)
laptop_status = await laptop.get("/api/v1/session") laptop_status = await laptop.get("/api/v1/session")
assert listed.status_code == 200 assert listed.status_code == 200
@ -303,6 +304,12 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
assert missing_csrf.status_code == 403 assert missing_csrf.status_code == 403
assert revoked.json() == {"revoked": True, "current_session": False} assert revoked.json() == {"revoked": True, "current_session": False}
assert phone_status.status_code == 401 assert phone_status.status_code == 401
assert phone_status.json() == {
"detail": "Authentication required",
"code": "session_revoked",
}
assert phone_page.status_code == 303
assert phone_page.headers["location"] == "login?reason=session-revoked"
assert laptop_status.status_code == 200 assert laptop_status.status_code == 200
assert "session_hash" not in listed.text assert "session_hash" not in listed.text
assert "csrf" not in listed.text assert "csrf" not in listed.text

View File

@ -92,6 +92,39 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedCaches"] == [] assert result["deletedCaches"] == []
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
assert result["replacedAfterDeletion"] is True
def test_worker_revocation_message_clears_window_storage_before_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session(): def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario( result = run_session_scenario(
""" """

View File

@ -37,6 +37,75 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
assert state["ignored"] == state["expired"] assert state["ignored"] == state["expired"]
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{cleared:false, snapshots:[]}};
const status = {{textContent:''}};
const button = {{disabled:false}};
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status, button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
location:{{replace:()=>{{}}}},
clearPrivateDeviceData:async()=>{{
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await new Promise(resolve=>setTimeout(resolve,10));
state.cleared=true;
}},
}});
(async()=>{{
const pending=controller.showReason('session-revoked');
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await pending;
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
process.stdout.write(JSON.stringify(state));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
assert state["final"] == {
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
"disabled": False,
"cleared": True,
}
@pytest.mark.anyio
async def test_login_loads_private_data_purger_before_controller():
html = await login()
assert '<script src="static/private-device-data.js"></script>' in html
assert html.index('static/private-device-data.js') < html.index('static/login.js')
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status={{textContent:''}};
const button={{disabled:false}};
const controller=createLoginController({{
form:{{reset:()=>{{}}}},status,button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
}});
(async()=>{{
await controller.showReason('session-revoked');
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
}})();
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this sites data before signing in.",
"disabled": True,
}
def test_rate_limited_login_disables_submit_and_counts_down(): def test_rate_limited_login_disables_submit_and_counts_down():
harness = f""" harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))}); const createLoginController = require({json.dumps(str(LOGIN_JS))});

View File

@ -0,0 +1,49 @@
import json
import subprocess
from pathlib import Path
PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-device-data.js"
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
harness = f"""
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
const storage = {{
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
get length(){{return this.values.size;}},
key(index){{return Array.from(this.values.keys())[index] || null;}},
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
}};
const clear = createPrivateDeviceDataPurger({{
localStorage:storage, sessionStorage:storage,
indexedDB:{{deleteDatabase:name=>{{
state.databases.push(name);
const request={{}};
setTimeout(()=>{{state.complete=true;request.onsuccess?.();}},10);
return request;
}}}},
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
MessageChannel:class{{constructor(){{
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
this.port1=first;this.port2=second;
}}}},
}});
(async()=>{{
await clear();
process.stdout.write(JSON.stringify({{...state,remaining:Array.from(storage.values.keys())}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["remaining"] == ["other.preference"]
assert state["databases"] == ["stackchain-background-outbox-v1"]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["complete"] is True

View File

@ -146,6 +146,26 @@ def test_background_delivery_preserves_structured_uncertain_error():
} }
def test_revoked_background_session_purges_worker_data_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_revoked'}),{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_revoked"}
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-revoked"}
]
def test_authenticated_page_message_resumes_queued_background_delivery(): def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario( result = run_worker_scenario(
""" """