diff --git a/README.md b/README.md index 35652d7..251ac92 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,15 @@ and registered with Background Sync, so a supporting installed browser can deliv new issues, issue comments, pull-request comments, and unread-update replies after every dashboard client has closed. The worker verifies the current Gitea login, shares an atomic delivery claim with the foreground path, and preserves the original -idempotency key. Browsers without +idempotency key. Installed browsers can explicitly enable **Notify me when queued +work finishes**; permission is requested only from that user gesture and the choice +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. +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. +Browsers without IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows queued and needs-attention messages with explicit send/discard controls; reopening the dashboard reconciles worker completions and permanent failures into the visible outbox. diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index ad79a9d..9d47feb 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -48,6 +48,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n const existing = await records.getAll(); const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }])); for (const current of existing) { + if (current.recordType === 'receipt-preference') continue; const currentLane = current.outboxLane || 'issue'; if (currentLane !== outboxLane) continue; const replacement = incoming.get(current.id); @@ -112,6 +113,27 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n }); } + function preferenceId(ownerLogin) { + return 'receipt-preference:' + String(ownerLogin || '').trim(); + } + + async function setReceiptPreference(ownerLogin, enabled) { + const login = String(ownerLogin || '').trim(); + if (!login) return; + return transact(async records => { + const id = preferenceId(login); + if (!enabled) return records.delete(id); + return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true }); + }); + } + + async function getReceiptPreference(ownerLogin) { + const id = preferenceId(ownerLogin); + return transact(async records => Boolean( + (await records.getAll()).find(item => item.id === id)?.enabled + )); + } + return { reconcile, upsert, @@ -120,13 +142,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })), release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })), fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })), - snapshot: () => transact(records => records.getAll()), + snapshot: () => transact(async records => + (await records.getAll()).filter(item => item.recordType !== 'receipt-preference')), countBlocked: ownerLogin => transact(async records => - (await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length), + (await records.getAll()).filter(item => item.recordType !== 'receipt-preference' && + item.status !== 'sent' && item.ownerLogin !== ownerLogin).length), + setReceiptPreference, + getReceiptPreference, }; } function createBackgroundIssueSync({ store, fetchJson, base = '' }) { + function receiptFor(item, status, delivered = {}) { + if (status === 'attention') { + return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' }; + } + if (item.kind === 'update-reply') { + return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) }; + } + const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); + if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { + const resource = item.kind === 'pull-comment' ? 'pull' : 'issue'; + return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) }; + } + return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) }; + } + function deliveryRequest(item) { if (item.kind === 'update-reply') { return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); @@ -179,12 +220,13 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { try { const delivered = await fetchJson(request.url, request.options); await store.complete(item.id); - return item.kind ? { message: delivered } : { issue: delivered }; + const receipt = receiptFor(item, 'confirmed', delivered); + return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; } catch (error) { const status = Number(error?.status || 0); if (status >= 400 && status < 500) { await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240)); - return { attention: true, error }; + return { attention: true, error, receipt: receiptFor(item, 'attention') }; } await store.release(item.id); throw error; @@ -205,8 +247,9 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { }); const login = String(identity?.login || '').trim(); const confirmed = []; + const receipts = []; let attention = 0; - if (!login) return { confirmed, blocked: 0, attention }; + if (!login) return { confirmed, blocked: 0, attention, login, receipts }; while (true) { const item = await store.claimNext(login); if (!item) break; @@ -214,15 +257,18 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { if (result.issue) confirmed.push(result.issue); if (result.message) confirmed.push(result.message); if (result.attention) attention += 1; + if (result.receipt) receipts.push(result.receipt); } const blocked = store.countBlocked ? await store.countBlocked(login) : 0; - return { confirmed, blocked, attention }; + return { confirmed, blocked, attention, login, receipts }; } return { flush, send, reconcile: (items, outboxLane) => store.reconcile(items, outboxLane), snapshot: () => store.snapshot(), + setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled), + getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin), }; } diff --git a/frontend/index.html b/frontend/index.html index 42f81db..cc1642e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -331,8 +331,10 @@ textarea { resize: vertical; min-height: 120px; }
+ +
@@ -2410,7 +2412,10 @@ textarea { resize: vertical; min-height: 120px; } const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && !contextFreshness?.degraded && !contextFreshness?.revalidating; activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; - if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin; + if (activeFlushLogin) { + confirmedOwnerLogin = activeFlushLogin; + updateDeliveryReceiptControls(); + } snapshot.context.notifications = lastNotifications; renderContextSnapshot(snapshot.context); if (contextFreshness?.stale) markMyWorkStale(); @@ -3357,12 +3362,33 @@ textarea { resize: vertical; min-height: 120px; } const offlineStatus = qs('#offline-status'); const keepWorkOffline = qs('#keep-work-offline'); const offlineWorkStatus = qs('#offline-work-status'); + const deliveryReceipts = qs('#delivery-receipts'); + const deliveryReceiptStatus = qs('#delivery-receipt-status'); function updateOfflineWorkControls(message) { keepWorkOffline.checked = offlineWorkStore.enabled(); const saved = offlineWorkStore.load(); offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' : (keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.')); } + async function updateDeliveryReceiptControls(message) { + const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator); + deliveryReceipts.disabled = !supported || !confirmedOwnerLogin; + if (!supported) { + deliveryReceipts.checked = false; + deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.'; + return; + } + if (!confirmedOwnerLogin) { + deliveryReceipts.checked = false; + deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.'; + return; + } + deliveryReceipts.checked = Notification.permission === 'granted' && + await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin); + deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked + ? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.' + : Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.'); + } function setOfflineWorkMode(value) { offlineWorkMode = value; ['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read'] @@ -3414,11 +3440,24 @@ textarea { resize: vertical; min-height: 120px; } } updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); }); + deliveryReceipts.addEventListener('change', async () => { + let enabled = deliveryReceipts.checked; + if (enabled && Notification.permission !== 'granted') { + enabled = await Notification.requestPermission() === 'granted'; + } + deliveryReceipts.checked = enabled; + if (backgroundIssueSync && confirmedOwnerLogin) { + await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled); + } + await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' : + (Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.')); + }); qs('#clear-offline-work').addEventListener('click', () => { offlineWorkStore.clear(); updateOfflineWorkControls('Offline work data cleared.'); }); updateOfflineWorkControls(); + updateDeliveryReceiptControls(); if (!navigator.onLine) showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); window.addEventListener('online', reconnectLiveData); @@ -3493,6 +3532,14 @@ textarea { resize: vertical; min-height: 120px; } updateWorkPaginationControls(); }); }); + function openDeliveryReceiptRoute() { + if (window.location.hash !== '#/my-work/drafts') return; + qs('[data-work-filter="draft"]').click(); + qs('#my-work').scrollIntoView({block:'start'}); + qs('#my-work').focus(); + } + window.addEventListener('hashchange', openDeliveryReceiptRoute); + openDeliveryReceiptRoute(); qs('#work-milestone-filter').addEventListener('change', event => { selectedWorkMilestone = event.target.value; try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 98d072f..8926c56 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-v16'; +const CACHE = 'stackchain-dashboard-shell-v17'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -64,6 +64,23 @@ const issueSync = self.__issueSync || createBackgroundIssueSync({ store: createIssueSyncStore(), fetchJson, base: BASE, }); +async function flushAndNotify() { + const result = await issueSync.flush(); + if (!result?.login || !result.receipts?.length || + !await issueSync.getReceiptPreference?.(result.login)) return; + for (const receipt of result.receipts) { + const needsAttention = receipt.status === 'attention'; + const title = needsAttention + ? 'Queued work needs attention' + : receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent'; + await self.registration.showNotification(title, { + body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.', + tag: 'stackchain-delivery-' + receipt.id, + data: { route: receipt.route }, + }); + } +} + self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())); }); @@ -76,7 +93,21 @@ self.addEventListener('activate', event => { }); self.addEventListener('sync', event => { - if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush()); + if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + const route = String(event.notification.data?.route || ''); + if (!route.startsWith('#/my-work/')) return; + const target = new URL(BASE + route, self.location.origin).href; + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); + if (!client) return self.clients.openWindow(target); + if (client.navigate) await client.navigate(target); + return client.focus(); + })()); }); self.addEventListener('fetch', event => { diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index f91a91a..46090a6 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -64,6 +64,59 @@ const fetchJson = async (url, options = {{}}) => {{ } +def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const queued = [ + {{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}}, + {{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}}, + {{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}}, +]; +const store = {{ + claimNext: async () => queued.shift() || null, + complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}}, + countBlocked: async () => 0, +}}; +let issueMutations = 0; +const fetchJson = async (url) => {{ + if (url === 'api/v1/background-identity') return {{login:'timmy'}}; + if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}}; + if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}}; + const error = new Error('Sensitive validation detail'); error.status = 422; throw error; +}}; +(async () => {{ + const result = await createBackgroundIssueSync({{store,fetchJson}}).flush(); + process.stdout.write(JSON.stringify(result)); +}})(); +""" + output = run_node(script) + + assert output["login"] == "timmy" + assert output["receipts"] == [ + { + "id": "capture-1", + "status": "confirmed", + "kind": "issue", + "route": "#/my-work/issue/stackchain/api/44", + }, + { + "id": "reply-1", + "status": "confirmed", + "kind": "message", + "route": "#/my-work/pull/stackchain/web/8", + }, + { + "id": "bad-1", + "status": "attention", + "kind": "issue", + "route": "#/my-work/drafts", + }, + ] + serialized = json.dumps(output["receipts"]) + assert "Secret" not in serialized + assert "Sensitive" not in serialized + + @pytest.mark.parametrize( ("item", "expected_url"), [ @@ -141,6 +194,47 @@ const transaction=work=>{{const run=tail.then(()=>work({{ ] +def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const records=new Map();let tail=Promise.resolve(); +const transaction=work=>{{const run=tail.then(()=>work({{ + getAll:async()=>[...records.values()].map(value=>({{...value}})), + put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id), +}}));tail=run.catch(()=>{{}});return run;}}; +(async()=>{{ + const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}}); + await store.setReceiptPreference('timmy', true); + await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue'); + const result={{ + timmy:await store.getReceiptPreference('timmy'), + alexander:await store.getReceiptPreference('alexander'), + snapshot:await store.snapshot(), + blocked:await store.countBlocked('alexander'), + }}; + await store.setReceiptPreference('timmy', false); + result.disabled=await store.getReceiptPreference('timmy'); + process.stdout.write(JSON.stringify(result)); +}})(); +""" + output = run_node(script) + + assert output == { + "timmy": True, + "alexander": False, + "snapshot": [ + { + "id": "issue", + "ownerLogin": "timmy", + "status": "queued", + "outboxLane": "issue", + } + ], + "blocked": 1, + "disabled": False, + } + + def test_closed_app_sync_leaves_another_accounts_issue_queued(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); @@ -392,3 +486,16 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback(): assert "issueOutbox.reconcileBackground(records);" in html assert "authoredOutbox.reconcileBackground(records);" in html assert "if ('indexedDB' in window)" in html + + +@pytest.mark.anyio +async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in(): + html = await dashboard() + + assert 'id="delivery-receipts" type="checkbox"' in html + assert "deliveryReceipts.addEventListener('change', async () => {" in html + assert "await Notification.requestPermission()" in html + assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html + assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html + assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html + assert "if (window.location.hash !== '#/my-work/drafts') return;" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 5e21b30..c0002d2 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }}; +const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }}; const cache = {{ addAll: async urls => {{ state.added = urls; }}, match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), @@ -26,8 +26,16 @@ const context = {{ location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, - clients: {{ claim: async () => {{ state.claimed = true; }} }}, - __issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }}, + clients: {{ + claim: async () => {{ state.claimed = true; }}, + matchAll: async () => state.clientList || [], + openWindow: async url => {{ state.opened.push(url); }}, + }}, + registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}}, + __issueSync: {{ + flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }}, + getReceiptPreference: async login => state.receiptLogin === login, + }}, }}, importScripts: () => {{}}, caches: {{ @@ -60,6 +68,14 @@ async function dispatchSync(tag) {{ listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} +async function dispatchNotificationClick(route) {{ + let pending; + listeners.notificationclick({{ + notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}}, + waitUntil: promise => {{ pending = promise; }}, + }}); + if (pending) await pending; +}} (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); @@ -70,10 +86,10 @@ async function dispatchSync(tag) {{ return json.loads(completed.stdout) -def test_operator_session_boundary_ships_in_a_new_shell_cache(): +def test_background_delivery_receipts_ship_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v16" in source + assert "stackchain-dashboard-shell-v17" in source def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): @@ -88,6 +104,46 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag( assert result["backgroundFlushes"] == 1 +def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route(): + result = run_worker_scenario( + """ + state.receiptLogin = 'timmy'; + state.flushResult = {login:'timmy', receipts:[ + {id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'}, + {id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'}, + ]}; + state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}]; + await dispatchSync('stackchain-issue-outbox-v1'); + await dispatchNotificationClick('#/my-work/issue/stackchain/api/44'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"] == [ + { + "title": "Queued issue created", + "options": { + "body": "Tap to open it in Stackchain.", + "tag": "stackchain-delivery-capture-1", + "data": {"route": "#/my-work/issue/stackchain/api/44"}, + }, + }, + { + "title": "Queued work needs attention", + "options": { + "body": "Tap to review it in Drafts.", + "tag": "stackchain-delivery-bad-1", + "data": {"route": "#/my-work/drafts"}, + }, + }, + ] + assert result["focused"] == [ + "https://forge.example/dashboard/#/my-work/issue/stackchain/api/44" + ] + assert result["opened"] == [] + assert result["notificationClosed"] is True + + def test_background_mutations_obtain_session_bound_csrf_proof(): source = WORKER.read_text()