From acd2d82d102326059e32c0eac99231a1da8bb307 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 05:32:29 +0000 Subject: [PATCH] fix: resume queued delivery after sign-in (#266) --- README.md | 4 +++ frontend/background-issue-sync.js | 4 +++ frontend/service-worker.js | 6 +++- frontend/session.js | 13 ++++++-- tests/test_background_issue_sync.py | 38 ++++++++++++++++++++++++ tests/test_dashboard_session_frontend.py | 14 ++++++++- tests/test_service_worker.py | 20 +++++++++++-- 7 files changed, 93 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 26848dc..e9a95cc 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ work finishes**; permission is requested only from that user gesture and the cho is stored for the confirmed account in the private background outbox database. Successful deliveries produce privacy-safe receipts that open the created issue or source conversation, while permanent validation failures open Drafts for recovery. +Authentication expiry during a worker mutation is recoverable: the worker releases the +claim without creating an Attention receipt, stops that drain, and keeps the original +idempotency key. Loading the authenticated dashboard after signing in asks the worker +to resume queued delivery automatically. Notification text never includes issue titles, comment bodies, or validation details. The preference is off by default, unsupported or denied browsers retain foreground reconciliation, and **Sign out & clear this device** removes the account-bound choice. diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 9d47feb..180c2c8 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -224,6 +224,10 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; } catch (error) { const status = Number(error?.status || 0); + if (status === 401) { + await store.release(item.id); + throw error; + } if (status >= 400 && status < 500) { await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240)); return { attention: true, error, receipt: receiptFor(item, 'attention') }; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8926c56..ceefae1 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v17'; +const CACHE = 'stackchain-dashboard-shell-v18'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -96,6 +96,10 @@ self.addEventListener('sync', event => { if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); }); +self.addEventListener('message', event => { + if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify()); +}); + self.addEventListener('notificationclick', event => { event.notification.close(); const route = String(event.notification.data?.route || ''); diff --git a/frontend/session.js b/frontend/session.js index 11eb04a..5e3dfac 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -12,6 +12,7 @@ sessionStorage: root.sessionStorage, indexedDB: root.indexedDB, caches: root.caches, + serviceWorker: root.navigator?.serviceWorker, location: root.location, onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), }); @@ -19,13 +20,14 @@ const attach = () => { const button = root.document.getElementById('sign-out'); if (button) button.addEventListener('click', () => boundary.signOut()); + boundary.resumeQueuedWork(); }; if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); else attach(); root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ - cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location, + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, onExpired = () => {}, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -88,5 +90,12 @@ } } - return { fetch: sessionFetch, signOut, clearPrivateDeviceData }; + async function resumeQueuedWork() { + try { + const registration = await serviceWorker?.ready; + registration?.active?.postMessage({ type: 'stackchain-resume-outbox' }); + } catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ } + } + + return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork }; }); diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 46090a6..7ba6fea 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -260,6 +260,44 @@ const fetchJson = async url => {{ assert output["result"]["blocked"] == 1 +def test_session_expiry_during_delivery_releases_claim_without_attention(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const queued = [ + {{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}}, + {{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}}, +]; +const state = {{released:[],failed:[],mutationKeys:[]}}; +const store = {{ + claimNext: async () => queued.shift() || null, + release: async id => state.released.push(id), + fail: async (id,message) => state.failed.push({{id,message}}), + countBlocked: async () => 0, +}}; +const fetchJson = async (url, options={{}}) => {{ + if (url === 'api/v1/background-identity') return {{login:'timmy'}}; + state.mutationKeys.push(options.headers['Idempotency-Key']); + const error = new Error('Authentication required'); error.status = 401; throw error; +}}; +(async () => {{ + let error = null; + try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }} + catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }} + process.stdout.write(JSON.stringify({{state,error}})); +}})(); +""" + output = run_node(script) + + assert output == { + "state": { + "released": ["capture-auth"], + "failed": [], + "mutationKeys": ["stable-key"], + }, + "error": {"message": "Authentication required", "status": 401}, + } + + def test_transient_delivery_failure_releases_claim_and_requests_another_sync(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 1cc297c..0d00a3f 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -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: [], deletedCaches: [], assigned: '' }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -33,6 +33,7 @@ const boundary = createSessionBoundary({{ sessionStorage: storage, indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }}, caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }}, + serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }}, location: {{ assign: value => {{ state.assigned = value; }} }}, }}); (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); @@ -72,6 +73,17 @@ process.stdout.write(JSON.stringify(state)); assert result["assigned"] == "/dashboard/login" +def test_authenticated_dashboard_load_requests_queued_delivery_resume(): + result = run_session_scenario( + """ +await boundary.resumeQueuedWork(); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}] + + @pytest.mark.anyio async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): html = await dashboard() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index c0002d2..affb17f 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -68,6 +68,11 @@ async function dispatchSync(tag) {{ listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} +async function dispatchMessage(data) {{ + let pending; + listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }}); + if (pending) await pending; +}} async function dispatchNotificationClick(route) {{ let pending; listeners.notificationclick({{ @@ -86,10 +91,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_background_delivery_receipts_ship_in_a_new_shell_cache(): +def test_session_resume_flow_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v17" in source + assert "stackchain-dashboard-shell-v18" in source def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): @@ -104,6 +109,17 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag( assert result["backgroundFlushes"] == 1 +def test_authenticated_page_message_resumes_queued_background_delivery(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-resume-outbox'}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["backgroundFlushes"] == 1 + + def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route(): result = run_worker_scenario( """