feat: notify devices when Following changes (Closes #1313)
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 17:16:28 +00:00
parent 187b0decae
commit 0efd44fd90
13 changed files with 491 additions and 7 deletions

View File

@ -122,6 +122,13 @@ final item completes the Following phase. Failed or unconfirmed Gitea mutations
and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
channel independently, validates the device session immediately before delivery, and checkpoints
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
unchanged.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
@ -398,7 +405,8 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
# Following alerts use generic copy and deep-link to the changed-first Following review.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.

View File

@ -8310,6 +8310,8 @@
startDayControl:qs('#push-start-day'),
startDayStatus:qs('#push-start-day-status'),
startDayHour:qs('#push-start-day-hour'),
followingControl:qs('#push-following'),
followingStatus:qs('#push-following-status'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),

View File

@ -221,6 +221,8 @@
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>

View File

@ -4,6 +4,7 @@
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
@ -129,12 +130,15 @@
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
if (startDayControl) startDayControl.checked = false;
if (followingControl) followingControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
configuration.start_day_enabled = false;
configuration.following_enabled = false;
pendingIntent = null;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
}
async function ensureSubscription() {
@ -266,13 +270,45 @@
}
}
async function changeFollowing() {
followingControl.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (followingControl.checked) pendingIntent = 'following';
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
if (followingControl.checked && !subscription) {
followingControl.checked = false;
followingStatus.textContent = status.textContent;
return false;
}
await fetchJson('api/v1/push-subscription/following', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:followingControl.checked}),
});
configuration.following_enabled = followingControl.checked;
pendingIntent = null;
followingStatus.textContent = followingControl.checked
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
return true;
} catch (_error) {
followingControl.checked = !followingControl.checked;
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
return false;
} finally {
followingControl.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
@ -284,6 +320,10 @@
startDayControl.checked = true;
return changeStartDay();
}
if (pendingIntent === 'following') {
followingControl.checked = true;
return changeFollowing();
}
return Boolean(await enable());
})();
try {
@ -299,18 +339,21 @@
testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay);
followingControl?.addEventListener('change', changeFollowing);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true;
if (followingControl) followingControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
@ -321,8 +364,11 @@
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
: 'Start-day reminders are off for this device.';
if (followingStatus) followingStatus.textContent = configuration.following_enabled
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});

View File

@ -649,6 +649,7 @@ self.addEventListener('push', event => {
const updateCount = Number(payload.update_count);
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count);
const followingCount = Number(payload.following_count);
const planDate = String(payload.plan_date || '');
if (
route === '#/my-work/start-day'
@ -685,6 +686,23 @@ self.addEventListener('push', event => {
));
return;
}
if (
route === '#/my-work/following'
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
&& Number.isSafeInteger(followingCount)
&& followingCount > 0
&& followingCount <= 50
) {
event.waitUntil(self.registration.showNotification(
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed',
{
body: 'Open Following to review the latest activity.',
tag,
data: {route},
}
));
return;
}
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'

View File

@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore
from src.push_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
dispatch_following_changes,
dispatch_start_day_reminders,
dispatch_unread_updates,
send_web_push,
@ -131,6 +132,10 @@ async def _start_day_plan_snapshot() -> dict:
return await asyncio.to_thread(_today_store().get_start_day_plan, login)
async def _following_push_snapshot() -> dict:
return await get_following(Response())
async def _push_poll_loop() -> None:
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
deadline_interval = max(
@ -173,6 +178,17 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency,
)
async def dispatch_following() -> None:
await dispatch_following_changes(
_push_subscription_store,
_push_configuration(),
_following_push_snapshot,
session_statuses=dashboard_auth.managed_session_statuses,
send_timeout_seconds=send_timeout,
lease_seconds=lease_seconds,
max_concurrency=max_concurrency,
)
async def dispatch_start_day() -> None:
await dispatch_start_day_reminders(
_push_subscription_store,
@ -186,6 +202,7 @@ async def _push_poll_loop() -> None:
channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
),
@ -474,6 +491,10 @@ class StartDayReminderPayload(BaseModel):
return value
class FollowingNotificationPayload(BaseModel):
enabled: bool
StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
@ -2322,6 +2343,9 @@ async def push_status(request: Request):
start_day_preferences = await asyncio.to_thread(
_push_subscription_store.start_day_preferences, device_id
)
following_preferences = await asyncio.to_thread(
_push_subscription_store.following_preferences, device_id
)
delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id
)
@ -2337,6 +2361,7 @@ async def push_status(request: Request):
"start_day_enabled": start_day_preferences["enabled"],
"start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
"following_enabled": following_preferences["enabled"],
"delivery_health": delivery_health,
}
@ -2485,6 +2510,25 @@ async def update_start_day_reminders(payload: StartDayReminderPayload, request:
}
@app.put("/api/v1/push-subscription/following")
async def update_following_notifications(
payload: FollowingNotificationPayload, request: Request
):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
if payload.enabled and not await asyncio.to_thread(
_push_subscription_store.is_subscribed, device_id
):
raise HTTPException(status_code=409, detail="Enable device notifications first")
await asyncio.to_thread(
_push_subscription_store.set_following_preferences,
device_id,
enabled=payload.enabled,
)
return {"following_enabled": payload.enabled}
@app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id(

View File

@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
import secrets
import time
@ -99,6 +100,135 @@ async def send_web_push(
)
async def dispatch_following_changes(
store: PushSubscriptionStore,
configuration: PushConfiguration,
following: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
) -> int:
"""Notify opted-in devices once for each privacy-safe Following change set."""
if not configuration.enabled:
return 0
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="following",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not acquired:
return 0
try:
devices = await asyncio.to_thread(store.following_notification_devices)
if not devices:
return 0
snapshot = await following()
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
return 0
changed = []
for item in snapshot.get("items", []):
if not isinstance(item, dict) or item.get("has_unseen_change") is not True:
continue
repository = item.get("repository")
kind = item.get("kind")
number = item.get("number")
updated_at = item.get("updated_at")
if (
isinstance(repository, str)
and kind in {"issue", "pull"}
and isinstance(number, int)
and not isinstance(number, bool)
and number > 0
and isinstance(updated_at, str)
and updated_at
):
changed.append((repository.lower(), kind, number, updated_at))
changed.sort()
fingerprint = hashlib.sha256(
json.dumps(changed, separators=(",", ":")).encode()
).hexdigest()
if not changed:
await asyncio.gather(*(
asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint)
for device in devices
))
return 0
pending = [device for device in devices if device.delivered_fingerprint != fingerprint]
if not pending:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses([device.session_id for device in pending])
except Exception:
return 0
for device in pending:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
pending = [
device for device in pending if statuses.get(device.session_id) == "active"
]
count = min(len(changed), 50)
payload = json.dumps({
"title": f"{count} watched item{'s' if count != 1 else ''} changed",
"body": "Open Following to review the latest activity.",
"route": "#/my-work/following",
"tag": f"stackchain-following-{fingerprint[:16]}",
"following_count": count,
}, separators=(",", ":"))
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="following",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not still_owner:
return 0
try:
operation = (
send(device.subscription, payload)
if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(store.delete_session, device.session_id)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
device.session_id,
"following",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "following"
)
await asyncio.to_thread(
store.mark_following_delivered, device.session_id, fingerprint
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device in pending), return_exceptions=True
)
return sum(result for result in results if isinstance(result, int))
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
async def dispatch_unread_updates(
store: PushSubscriptionStore,
configuration: PushConfiguration,

View File

@ -52,6 +52,13 @@ class StartDayReminderDevice:
delivered_plan_date: str | None
@dataclass(frozen=True)
class FollowingNotificationDevice:
session_id: str
subscription: dict
delivered_fingerprint: str | None
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
@ -81,6 +88,12 @@ class DisabledPushSubscriptionStore:
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
return []
def following_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
return []
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
return []
@ -117,6 +130,12 @@ class DisabledPushSubscriptionStore:
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
return None
def set_following_preferences(self, *args, **kwargs) -> None:
return None
def mark_following_delivered(self, *args, **kwargs) -> None:
return None
def reconcile_unread(self, *args, **kwargs) -> None:
return None
@ -236,6 +255,13 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_following_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
delivered_fingerprint TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
@ -569,6 +595,46 @@ class PushSubscriptionStore:
(plan_date, session_id),
)
def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_following_preferences(session_id, enabled)
VALUES (?, ?)
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
(session_id, int(enabled)),
)
def following_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT enabled FROM push_following_preferences WHERE session_id = ?",
(session_id,),
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
FROM push_subscriptions s
JOIN push_following_preferences p ON p.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
return [
FollowingNotificationDevice(
row[0], self._open_subscription(row[0], row[1]), row[2]
)
for row in rows
]
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_following_preferences SET delivered_fingerprint = ?
WHERE session_id = ? AND enabled = 1""",
(fingerprint, session_id),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:

View File

@ -29,6 +29,12 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1)
expect(row).to_contain_text("Issues and pull requests you watch")
following_alert = page.locator('label[for="push-following"]')
expect(following_alert).to_have_count(1)
expect(following_alert).to_contain_text("Notify me when Following changes")
page.locator("#work-settings-toggle").click()
expect(following_alert).to_be_visible()
assert following_alert.bounding_box()["height"] >= 44
page.evaluate("""() => {
globalThis.fetch = async () => ({
ok:true,

View File

@ -4,6 +4,8 @@ from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
INDEX = MODULE.parent / "index.html"
DASHBOARD = MODULE.parent / "dashboard.js"
def run_scenario(script: str) -> dict:
@ -27,6 +29,11 @@ const startDayControl = {
};
const startDayHour = {value:'9', disabled:false};
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }};
const followingControl = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.followingChange = callback,
};
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true};
@ -40,6 +47,7 @@ const registration = {pushManager:{
const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
@ -77,6 +85,35 @@ process.stdout.write(JSON.stringify(state));
assert result["text"] == "New update notifications enabled for this device."
def test_following_alert_toggle_is_opt_in_and_does_not_change_other_channels():
result = run_scenario("""
state.server = {available:true,subscribed:true,following_enabled:false,deadline_enabled:true,start_day_enabled:true,public_key:'AQID'};
state.current = existing;
await feature.init();
followingControl.checked = true;
await state.followingChange();
process.stdout.write(JSON.stringify({requests:state.requests, following:followingControl.checked, deadline:deadlineControl.checked, startDay:startDayControl.checked, text:state.followingText}));
""")
assert result["following"] is True
assert result["deadline"] is True
assert result["startDay"] is True
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/following", "PUT"]
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
assert result["text"] == "Following change alerts enabled for this device."
def test_device_settings_render_and_wire_the_following_alert_preference():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'for="push-following"' in index
assert 'id="push-following" type="checkbox"' in index
assert 'id="push-following-status" role="status" aria-live="polite"' in index
assert "followingControl:qs('#push-following')" in dashboard
assert "followingStatus:qs('#push-following-status')" in dashboard
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};

View File

@ -12,6 +12,7 @@ import requests
from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import (
PushConfiguration,
dispatch_following_changes,
dispatch_unread_updates,
send_web_push,
)
@ -41,6 +42,98 @@ def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
assert second.release_dispatch_lease("worker-b") is True
def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
assert store.following_preferences("session-a") == {"enabled": False}
store.set_following_preferences("session-a", enabled=True)
devices = store.following_notification_devices()
assert [(device.session_id, device.delivered_fingerprint) for device in devices] == [
("session-a", None)
]
store.mark_following_delivered("session-a", "revision-fingerprint")
assert store.following_notification_devices()[0].delivered_fingerprint == "revision-fingerprint"
assert store.following_preferences("session-b") == {"enabled": False}
@pytest.mark.anyio
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("active", "revoked", "disabled"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_following_preferences("active", enabled=True)
store.set_following_preferences("revoked", enabled=True)
sent = []
async def following():
return {"items": [{
"repository": "secret/project", "kind": "issue", "number": 42,
"title": "Sensitive acquisition", "updated_at": "2026-08-23T17:00:00Z",
"has_unseen_change": True,
}]}
async def send(subscription, payload):
sent.append((subscription["endpoint"], json.loads(payload)))
async def statuses(session_ids):
return {session_id: ("active" if session_id == "active" else "revoked") for session_id in session_ids}
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_following_changes(
store, configuration, following, send, session_statuses=statuses
) == 1
assert sent[0][0].endswith("/active")
assert sent[0][1] == {
"title": "1 watched item changed",
"body": "Open Following to review the latest activity.",
"route": "#/my-work/following",
"tag": sent[0][1]["tag"],
"following_count": 1,
}
assert sent[0][1]["tag"].startswith("stackchain-following-")
assert "secret" not in json.dumps(sent[0][1]).lower()
assert "acquisition" not in json.dumps(sent[0][1]).lower()
assert await dispatch_following_changes(
store, configuration, following, send, session_statuses=statuses
) == 0
assert len(sent) == 1
assert store.subscription_for_session("revoked") is None
@pytest.mark.anyio
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
async def management_id(_session):
return "session-a"
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
result = await main.update_following_notifications(
main.FollowingNotificationPayload(enabled=True), request
)
assert result == {"following_enabled": True}
assert (await main.push_status(request))["following_enabled"] is True
assert store.deadline_preferences("session-a")["enabled"] is False
assert store.start_day_preferences("session-a")["enabled"] is False
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
state_dir = tmp_path / "push-state"
previous_umask = os.umask(0)
@ -415,6 +508,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
@ -437,8 +531,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def no_wait(_seconds):
nonlocal sleeps
sleeps += 1
if sleeps <= 3:
if sleeps == 3:
if sleeps <= 4:
if sleeps == 4:
first_tick.set()
await first_tick.wait()
else:
@ -454,15 +548,19 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def dispatch_start_day(*_args, **_kwargs):
calls.append("start-day")
async def dispatch_following(*_args, **_kwargs):
calls.append("following")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
assert sorted(calls) == ["deadline", "start-day", "unread"]
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
@pytest.mark.anyio
@ -487,6 +585,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
@ -513,7 +612,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
await main._push_poll_loop()
assert sorted(intervals) == [30.0, 600.0, 600.0]
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
@pytest.mark.anyio
@ -1282,6 +1381,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_enabled": False,
"start_day_timezone": "UTC",
"start_day_reminder_hour": 9,
"following_enabled": False,
"delivery_health": {},
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}

View File

@ -1797,3 +1797,27 @@ def test_client_error_navigation_is_not_hidden_by_cached_shell():
assert result["status"] == 401
assert result["body"] == "network"
assert result["state"]["puts"] == []
def test_following_push_uses_generic_copy_and_opens_the_following_route():
result = run_worker_scenario("""
await dispatchPush({
title:'leaked title', body:'leaked body', route:'#/my-work/following',
tag:'stackchain-following-0123456789abcdef', following_count:2,
});
await dispatchNotificationClick(
'#/my-work/following', '', null, 'stackchain-following-0123456789abcdef'
);
console.log(JSON.stringify({notifications:state.notifications,opened:state.opened,closed:state.notificationClosed}));
""")
assert result["notifications"] == [{
"title": "2 watched items changed",
"options": {
"body": "Open Following to review the latest activity.",
"tag": "stackchain-following-0123456789abcdef",
"data": {"route": "#/my-work/following"},
},
}]
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/following"]
assert result["closed"] is True

View File

@ -208,6 +208,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)