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()