feat: notify mobile operators of unread updates (Closes #549)
This commit is contained in:
parent
83702e75b0
commit
6e90e3fa9e
|
|
@ -187,6 +187,14 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||||
|
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||||
|
# The feature stays disabled unless all three values are present.
|
||||||
|
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||||
|
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||||
|
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||||
|
# Optional; defaults to 30 seconds and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3.
|
||||||
|
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||||
|
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
|
||||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.offline-status[hidden] { display:none; }
|
.offline-status[hidden] { display:none; }
|
||||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||||
|
.push-update-control { min-height:44px; }
|
||||||
.offline-work-controls input { width:20px; height:20px; }
|
.offline-work-controls input { width:20px; height:20px; }
|
||||||
.offline-work-controls button { min-height:44px; }
|
.offline-work-controls button { min-height:44px; }
|
||||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,7 @@
|
||||||
document,
|
document,
|
||||||
urls: {
|
urls: {
|
||||||
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
||||||
|
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let sharedLaunchState = null;
|
let sharedLaunchState = null;
|
||||||
|
|
@ -5270,9 +5271,22 @@
|
||||||
if (workSession.active()) workSession.reconcile();
|
if (workSession.active()) workSession.reconcile();
|
||||||
});
|
});
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('service-worker.js').catch(error =>
|
navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||||
console.warn('Stackchain install support unavailable', error)
|
await issueCaptureFeatures.load('push-notifications');
|
||||||
);
|
return createPushNotifications({
|
||||||
|
control:qs('#push-updates'),
|
||||||
|
status:qs('#push-update-status'),
|
||||||
|
notification:window.Notification,
|
||||||
|
serviceWorker:navigator.serviceWorker,
|
||||||
|
fetchJson:fetchReviewJson,
|
||||||
|
}).init();
|
||||||
|
}).catch(error => {
|
||||||
|
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
|
||||||
|
console.warn('Push notifications unavailable', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
qs('#push-updates').disabled = true;
|
||||||
|
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||||||
}
|
}
|
||||||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@
|
||||||
<div class="offline-work-controls">
|
<div class="offline-work-controls">
|
||||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||||
|
<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 id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
|
|
@ -777,6 +779,7 @@
|
||||||
<script src="static/mobile-search-viewport.js"></script>
|
<script src="static/mobile-search-viewport.js"></script>
|
||||||
<script src="static/mobile-composer-viewport.js"></script>
|
<script src="static/mobile-composer-viewport.js"></script>
|
||||||
<script src="static/mention-composer.js"></script>
|
<script src="static/mention-composer.js"></script>
|
||||||
|
<script src="static/push-notifications.js"></script>
|
||||||
<script src="static/dashboard.js"></script>
|
<script src="static/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
79
frontend/push-notifications.js
Normal file
79
frontend/push-notifications.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
(function(root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
|
else root.createPushNotifications = factory;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||||
|
control, status, notification, serviceWorker, fetchJson,
|
||||||
|
}) {
|
||||||
|
let configuration = null;
|
||||||
|
|
||||||
|
function applicationServerKey(value) {
|
||||||
|
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||||
|
if (typeof atob === 'function') {
|
||||||
|
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||||
|
}
|
||||||
|
return Uint8Array.from(Buffer.from(padded, 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
const registration = await serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||||
|
await subscription?.unsubscribe?.();
|
||||||
|
control.checked = false;
|
||||||
|
status.textContent = 'New update notifications are off for this device.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enable() {
|
||||||
|
const permission = await notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
control.checked = false;
|
||||||
|
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const registration = await serviceWorker.ready;
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: applicationServerKey(configuration.public_key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fetchJson('api/v1/push-subscription', {
|
||||||
|
method:'PUT',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify(subscription.toJSON()),
|
||||||
|
});
|
||||||
|
control.checked = true;
|
||||||
|
status.textContent = 'New update notifications enabled for this device.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function change() {
|
||||||
|
control.disabled = true;
|
||||||
|
try {
|
||||||
|
if (control.checked) await enable();
|
||||||
|
else await disable();
|
||||||
|
} catch (error) {
|
||||||
|
control.checked = !control.checked;
|
||||||
|
status.textContent = 'Could not change update notifications. Check your connection and try again.';
|
||||||
|
} finally {
|
||||||
|
control.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!control || !notification || !serviceWorker) return;
|
||||||
|
control.addEventListener('change', change);
|
||||||
|
configuration = await fetchJson('api/v1/push-subscription');
|
||||||
|
if (!configuration.available) {
|
||||||
|
control.disabled = true;
|
||||||
|
status.textContent = 'New update notifications are not available on this server.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
control.checked = Boolean(configuration.subscribed);
|
||||||
|
status.textContent = configuration.subscribed
|
||||||
|
? 'New update notifications enabled for this device.'
|
||||||
|
: 'New update notifications are off for this device.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {init, change};
|
||||||
|
});
|
||||||
|
|
@ -63,6 +63,7 @@ const SHELL = [
|
||||||
BASE + 'static/mobile-search-viewport.js',
|
BASE + 'static/mobile-search-viewport.js',
|
||||||
BASE + 'static/mobile-composer-viewport.js',
|
BASE + 'static/mobile-composer-viewport.js',
|
||||||
BASE + 'static/mention-composer.js',
|
BASE + 'static/mention-composer.js',
|
||||||
|
BASE + 'static/push-notifications.js',
|
||||||
BASE + 'static/background-issue-sync.js',
|
BASE + 'static/background-issue-sync.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -268,6 +269,20 @@ self.addEventListener('message', event => {
|
||||||
})());
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
let payload;
|
||||||
|
try { payload = event.data?.json?.() || {}; }
|
||||||
|
catch (_error) { return; }
|
||||||
|
const route = String(payload.route || '');
|
||||||
|
const tag = String(payload.tag || '');
|
||||||
|
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||||
|
event.waitUntil(self.registration.showNotification('New work update', {
|
||||||
|
body: 'Tap to review it in Stackchain.',
|
||||||
|
tag,
|
||||||
|
data: {route},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', event => {
|
self.addEventListener('notificationclick', event => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const route = String(event.notification.data?.route || '');
|
const route = String(event.notification.data?.route || '');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ httpx==0.28.1
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.22
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
|
pywebpush==2.1.2
|
||||||
rjsmin==1.2.5
|
rjsmin==1.2.5
|
||||||
uvicorn==0.41.0
|
uvicorn==0.41.0
|
||||||
webauthn==3.0.0
|
webauthn==3.0.0
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||||
FEATURE_SOURCES = {
|
FEATURE_SOURCES = {
|
||||||
"issue-capture": ("static/create-issue-sheet.js",),
|
"issue-capture": ("static/create-issue-sheet.js",),
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||||
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||||
|
|
|
||||||
130
src/main.py
130
src/main.py
|
|
@ -47,6 +47,8 @@ from src.live_snapshot_store import (
|
||||||
)
|
)
|
||||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||||
from src.passkey_store import PasskeyStore
|
from src.passkey_store import PasskeyStore
|
||||||
|
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||||
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
|
|
@ -76,16 +78,37 @@ async def _drain_authored_action_operations() -> None:
|
||||||
_authored_action_operations.pop(key, None)
|
_authored_action_operations.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_poll_loop() -> None:
|
||||||
|
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
try:
|
||||||
|
await dispatch_unread_updates(
|
||||||
|
_push_subscription_store,
|
||||||
|
_push_configuration(),
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Gitea and push endpoints are external; one failed poll must not
|
||||||
|
# stop later delivery attempts.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
global _live_snapshot_task, _available_issue_snapshot_task
|
global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task
|
||||||
gitea_proxy.start_client()
|
gitea_proxy.start_client()
|
||||||
|
if _push_configuration().enabled:
|
||||||
|
_push_poll_task = asyncio.create_task(_push_poll_loop())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
live_task = _live_snapshot_task
|
live_task = _live_snapshot_task
|
||||||
available_task = _available_issue_snapshot_task
|
available_task = _available_issue_snapshot_task
|
||||||
for task in (live_task, available_task):
|
push_task = _push_poll_task
|
||||||
|
for task in (live_task, available_task, push_task):
|
||||||
if task is not None and not task.done():
|
if task is not None and not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
|
|
@ -100,6 +123,8 @@ async def lifespan(_app: FastAPI):
|
||||||
_live_snapshot_task = None
|
_live_snapshot_task = None
|
||||||
if _available_issue_snapshot_task is available_task:
|
if _available_issue_snapshot_task is available_task:
|
||||||
_available_issue_snapshot_task = None
|
_available_issue_snapshot_task = None
|
||||||
|
if _push_poll_task is push_task:
|
||||||
|
_push_poll_task = None
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||||
|
|
@ -135,6 +160,7 @@ AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
|
||||||
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
||||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
_live_snapshot_task: asyncio.Task | None = None
|
_live_snapshot_task: asyncio.Task | None = None
|
||||||
|
_push_poll_task: asyncio.Task | None = None
|
||||||
_live_snapshot_value: dict | None = None
|
_live_snapshot_value: dict | None = None
|
||||||
_live_snapshot_created_at: float | None = None
|
_live_snapshot_created_at: float | None = None
|
||||||
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
|
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
|
||||||
|
|
@ -182,6 +208,17 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore(
|
||||||
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_push_subscription_store = PushSubscriptionStore(
|
||||||
|
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _push_configuration() -> PushConfiguration:
|
||||||
|
return PushConfiguration(
|
||||||
|
os.getenv("STACKCHAIN_VAPID_PUBLIC_KEY", "").strip(),
|
||||||
|
os.getenv("STACKCHAIN_VAPID_PRIVATE_KEY", "").strip(),
|
||||||
|
os.getenv("STACKCHAIN_VAPID_SUBJECT", "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContextPayloadError(ValueError):
|
class ContextPayloadError(ValueError):
|
||||||
|
|
@ -205,6 +242,20 @@ class DashboardSignIn(BaseModel):
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionPayload(BaseModel):
|
||||||
|
endpoint: str = Field(min_length=12, max_length=2_048, pattern=r"^https://")
|
||||||
|
keys: dict[str, str]
|
||||||
|
|
||||||
|
@field_validator("keys")
|
||||||
|
@classmethod
|
||||||
|
def validate_push_keys(cls, value: dict[str, str]) -> dict[str, str]:
|
||||||
|
if set(value) != {"p256dh", "auth"} or any(
|
||||||
|
not item or len(item) > 1_024 for item in value.values()
|
||||||
|
):
|
||||||
|
raise ValueError("Valid Web Push keys are required")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
StepUpAction = Literal[
|
StepUpAction = Literal[
|
||||||
"merge_pull",
|
"merge_pull",
|
||||||
"submit_pull_review",
|
"submit_pull_review",
|
||||||
|
|
@ -859,7 +910,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(request)
|
path = dashboard_auth.application_path(request)
|
||||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events"} or path.startswith("/api/v1/work/") or (
|
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -1631,6 +1682,67 @@ async def session_status(request: Request):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/push-subscription")
|
||||||
|
async def push_status(request: Request):
|
||||||
|
configuration = _push_configuration()
|
||||||
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
request.state.dashboard_session
|
||||||
|
)
|
||||||
|
subscribed = await asyncio.to_thread(
|
||||||
|
_push_subscription_store.is_subscribed,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"available": configuration.enabled,
|
||||||
|
"subscribed": subscribed,
|
||||||
|
"public_key": configuration.public_key if configuration.enabled else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/push-subscription")
|
||||||
|
async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
|
||||||
|
if not _push_configuration().enabled:
|
||||||
|
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||||
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
request.state.dashboard_session
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_push_subscription_store.upsert,
|
||||||
|
device_id,
|
||||||
|
payload.model_dump(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
current = await asyncio.wait_for(notifications(), NOTIFICATION_PAGE_TIMEOUT_SECONDS)
|
||||||
|
except Exception as error:
|
||||||
|
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Unread updates are temporarily unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
) from error
|
||||||
|
existing_ids = {
|
||||||
|
int(item["notification_id"])
|
||||||
|
for item in current.get("items", [])
|
||||||
|
if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit()
|
||||||
|
}
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_push_subscription_store.mark_delivered, device_id, existing_ids
|
||||||
|
)
|
||||||
|
return {"subscribed": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/push-subscription")
|
||||||
|
async def unsubscribe_push(request: Request):
|
||||||
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
request.state.dashboard_session
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_push_subscription_store.delete_session,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
return {"subscribed": False}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/session/activity")
|
@app.post("/api/v1/session/activity")
|
||||||
async def record_session_activity(request: Request):
|
async def record_session_activity(request: Request):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
|
@ -1776,6 +1888,10 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||||
@app.delete("/api/v1/session")
|
@app.delete("/api/v1/session")
|
||||||
async def sign_out(request: Request, response: Response):
|
async def sign_out(request: Request, response: Response):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
try:
|
||||||
|
push_device_id = await dashboard_auth.session_management_id(session)
|
||||||
|
except (dashboard_auth.SessionStoreError, AttributeError):
|
||||||
|
push_device_id = None
|
||||||
journal = _security_event_store()
|
journal = _security_event_store()
|
||||||
try:
|
try:
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
|
|
@ -1808,6 +1924,10 @@ async def sign_out(request: Request, response: Response):
|
||||||
# successful revocation as failed merely because completion could not
|
# successful revocation as failed merely because completion could not
|
||||||
# be marked yet.
|
# be marked yet.
|
||||||
pass
|
pass
|
||||||
|
if push_device_id is not None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_push_subscription_store.delete_session, push_device_id
|
||||||
|
)
|
||||||
path = dashboard_auth.cookie_path(request)
|
path = dashboard_auth.cookie_path(request)
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
dashboard_auth.SESSION_COOKIE,
|
dashboard_auth.SESSION_COOKIE,
|
||||||
|
|
@ -1908,6 +2028,9 @@ async def revoke_active_device(
|
||||||
)
|
)
|
||||||
if not revoked:
|
if not revoked:
|
||||||
raise HTTPException(status_code=404, detail="Active device not found")
|
raise HTTPException(status_code=404, detail="Active device not found")
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_push_subscription_store.delete_session, management_id
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(journal.finalize, operation_id)
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -1950,6 +2073,7 @@ async def sign_out_all_devices(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
await asyncio.to_thread(_push_subscription_store.delete_all)
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(journal.finalize, operation_id)
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
|
||||||
74
src/push_notifications.py
Normal file
74
src/push_notifications.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PushConfiguration:
|
||||||
|
public_key: str
|
||||||
|
private_key: str
|
||||||
|
subject: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self.public_key and self.private_key and self.subject)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_web_push(
|
||||||
|
subscription: dict, payload: str, configuration: PushConfiguration
|
||||||
|
) -> None:
|
||||||
|
from pywebpush import webpush
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
webpush,
|
||||||
|
subscription_info=subscription,
|
||||||
|
data=payload,
|
||||||
|
vapid_private_key=configuration.private_key,
|
||||||
|
vapid_claims={"sub": configuration.subject},
|
||||||
|
ttl=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_unread_updates(
|
||||||
|
store: PushSubscriptionStore,
|
||||||
|
configuration: PushConfiguration,
|
||||||
|
unread: Callable[[], Awaitable[dict]],
|
||||||
|
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||||
|
) -> int:
|
||||||
|
if not configuration.enabled:
|
||||||
|
return 0
|
||||||
|
page = await unread()
|
||||||
|
thread_ids = {
|
||||||
|
int(item["notification_id"])
|
||||||
|
for item in page.get("items", [])
|
||||||
|
if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit()
|
||||||
|
}
|
||||||
|
count = 0
|
||||||
|
for delivery in store.claim_unseen(thread_ids):
|
||||||
|
for thread_id in delivery.thread_ids:
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"title": "New work update",
|
||||||
|
"body": "Tap to review it in Stackchain.",
|
||||||
|
"route": f"#/my-work/update/{thread_id}",
|
||||||
|
"tag": f"stackchain-update-{thread_id}",
|
||||||
|
},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if send is None:
|
||||||
|
await send_web_push(delivery.subscription, payload, configuration)
|
||||||
|
else:
|
||||||
|
await send(delivery.subscription, payload)
|
||||||
|
except Exception as error:
|
||||||
|
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||||
|
if status in {404, 410}:
|
||||||
|
store.delete_session(delivery.session_id)
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
store.mark_delivered(delivery.session_id, (thread_id,))
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
96
src/push_subscription_store.py
Normal file
96
src/push_subscription_store.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PushDelivery:
|
||||||
|
session_id: str
|
||||||
|
subscription: dict
|
||||||
|
thread_ids: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionStore:
|
||||||
|
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
subscription_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS push_deliveries (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
thread_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, thread_id),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
connection = sqlite3.connect(self.path, timeout=2)
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def upsert(self, session_id: str, subscription: dict) -> None:
|
||||||
|
endpoint = subscription["endpoint"]
|
||||||
|
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
|
||||||
|
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
|
||||||
|
(session_id, endpoint, encoded),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||||
|
|
||||||
|
def delete_all(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("DELETE FROM push_subscriptions")
|
||||||
|
|
||||||
|
def is_subscribed(self, session_id: str) -> bool:
|
||||||
|
with self._connect() as connection:
|
||||||
|
return connection.execute(
|
||||||
|
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
def claim_unseen(self, thread_ids: Iterable[int]) -> list[PushDelivery]:
|
||||||
|
candidates = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
||||||
|
).fetchall()
|
||||||
|
deliveries = []
|
||||||
|
for session_id, encoded in rows:
|
||||||
|
delivered = {
|
||||||
|
row[0]
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT thread_id FROM push_deliveries WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
unseen = tuple(value for value in candidates if value not in delivered)
|
||||||
|
if unseen:
|
||||||
|
deliveries.append(PushDelivery(session_id, json.loads(encoded), unseen))
|
||||||
|
return deliveries
|
||||||
|
|
||||||
|
def mark_delivered(self, session_id: str, thread_ids: Iterable[int]) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT OR IGNORE INTO push_deliveries(session_id, thread_id) VALUES (?, ?)",
|
||||||
|
((session_id, int(thread_id)) for thread_id in thread_ids),
|
||||||
|
)
|
||||||
|
|
@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
first = build_frontend(FRONTEND)
|
first = build_frontend(FRONTEND)
|
||||||
|
|
||||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
|
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"}
|
||||||
capture = first.feature_bundles["issue-capture"]
|
capture = first.feature_bundles["issue-capture"]
|
||||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||||
|
|
|
||||||
77
tests/test_push_frontend.py
Normal file
77
tests/test_push_frontend.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(script: str) -> dict:
|
||||||
|
harness = r"""
|
||||||
|
const createPushNotifications = require(__MODULE__);
|
||||||
|
const state = { prompts:0, requests:[], subscriptions:[], text:'' };
|
||||||
|
const control = {
|
||||||
|
checked:false, disabled:false,
|
||||||
|
addEventListener:(_name, callback) => state.change = callback,
|
||||||
|
};
|
||||||
|
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||||
|
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||||
|
const registration = {pushManager:{
|
||||||
|
getSubscription: async () => state.current || null,
|
||||||
|
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||||
|
}};
|
||||||
|
const feature = createPushNotifications({
|
||||||
|
control, status,
|
||||||
|
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||||
|
serviceWorker: {ready:Promise.resolve(registration)},
|
||||||
|
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||||
|
});
|
||||||
|
(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||||
|
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", "-e", harness], capture_output=True, check=True, text=True
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialization_reports_availability_without_prompting_for_permission():
|
||||||
|
result = run_scenario("await feature.init(); process.stdout.write(JSON.stringify(state));")
|
||||||
|
|
||||||
|
assert result["prompts"] == 0
|
||||||
|
assert result["requests"] == [["api/v1/push-subscription", "GET", ""]]
|
||||||
|
assert result["text"] == "New update notifications are off for this device."
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_gesture_subscribes_device_and_reports_enabled_state():
|
||||||
|
result = run_scenario("""
|
||||||
|
await feature.init();
|
||||||
|
control.checked = true;
|
||||||
|
await state.change();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["prompts"] == 1
|
||||||
|
assert result["subscriptions"][0]["userVisibleOnly"] is True
|
||||||
|
assert result["requests"][-1][0:2] == ["api/v1/push-subscription", "PUT"]
|
||||||
|
assert json.loads(result["requests"][-1][2])["endpoint"] == "https://push.example/device"
|
||||||
|
assert result["text"] == "New update notifications enabled for this device."
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||||
|
root = MODULE.parents[1]
|
||||||
|
html = (root / "frontend" / "index.html").read_text()
|
||||||
|
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||||
|
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||||
|
css = (root / "frontend" / "dashboard.css").read_text()
|
||||||
|
requirements = (root / "requirements.txt").read_text()
|
||||||
|
readme = (root / "README.md").read_text()
|
||||||
|
|
||||||
|
assert 'id="push-updates"' in html
|
||||||
|
assert 'id="push-update-status"' in html
|
||||||
|
assert '<script src="static/push-notifications.js"></script>' in html
|
||||||
|
assert "createPushNotifications({" in dashboard
|
||||||
|
assert "BASE + 'static/push-notifications.js'" in worker
|
||||||
|
assert ".push-update-control" in css and "min-height:44px" in css
|
||||||
|
assert "pywebpush==" in requirements
|
||||||
|
assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme
|
||||||
|
assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme
|
||||||
166
tests/test_push_notifications.py
Normal file
166
tests/test_push_notifications.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||||
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
subscription = {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
store.upsert("session-a", subscription)
|
||||||
|
|
||||||
|
first = store.claim_unseen({41, 42})
|
||||||
|
store.mark_delivered("session-a", first[0].thread_ids)
|
||||||
|
second = store.claim_unseen({41, 42})
|
||||||
|
|
||||||
|
assert [(item.session_id, item.thread_ids) for item in first] == [
|
||||||
|
("session-a", (41, 42))
|
||||||
|
]
|
||||||
|
assert second == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
original = {
|
||||||
|
"endpoint": "https://push.example/original",
|
||||||
|
"keys": {"p256dh": "old-key", "auth": "old-auth"},
|
||||||
|
}
|
||||||
|
replacement = {
|
||||||
|
"endpoint": "https://push.example/replacement",
|
||||||
|
"keys": {"p256dh": "new-key", "auth": "new-auth"},
|
||||||
|
}
|
||||||
|
store.upsert("session-a", original)
|
||||||
|
store.claim_unseen({9})
|
||||||
|
|
||||||
|
store.upsert("session-a", replacement)
|
||||||
|
delivery = store.claim_unseen({9})
|
||||||
|
store.delete_session("session-a")
|
||||||
|
|
||||||
|
assert delivery[0].subscription == replacement
|
||||||
|
assert delivery[0].thread_ids == (9,)
|
||||||
|
assert store.claim_unseen({10}) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("session-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def unread():
|
||||||
|
return {"items": [
|
||||||
|
{"notification_id": 42, "repository": "private/repo", "title": "Secret title"},
|
||||||
|
]}
|
||||||
|
|
||||||
|
async def send(subscription, payload):
|
||||||
|
sent.append((subscription, json.loads(payload)))
|
||||||
|
|
||||||
|
config = PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com")
|
||||||
|
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||||
|
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||||
|
assert sent[0][1] == {
|
||||||
|
"title": "New work update",
|
||||||
|
"body": "Tap to review it in Stackchain.",
|
||||||
|
"route": "#/my-work/update/42",
|
||||||
|
"tag": "stackchain-update-42",
|
||||||
|
}
|
||||||
|
assert "private/repo" not in json.dumps(sent)
|
||||||
|
assert "Secret title" not in json.dumps(sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("session-a", {
|
||||||
|
"endpoint": "https://push.example/expired",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
|
||||||
|
class Expired(Exception):
|
||||||
|
response = SimpleNamespace(status_code=410)
|
||||||
|
|
||||||
|
async def unread():
|
||||||
|
return {"items": [{"notification_id": 8}]}
|
||||||
|
|
||||||
|
async def send(_subscription, _payload):
|
||||||
|
raise Expired()
|
||||||
|
|
||||||
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||||
|
assert store.is_subscribed("session-a") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(tmp_path, monkeypatch):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_push_configuration",
|
||||||
|
lambda: PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com"),
|
||||||
|
)
|
||||||
|
async def management_id(_session):
|
||||||
|
return "session-a"
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||||
|
|
||||||
|
async def no_unread():
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "notifications", no_unread)
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(
|
||||||
|
dashboard_session=SimpleNamespace(session_id="session-a")
|
||||||
|
))
|
||||||
|
payload = main.PushSubscriptionPayload(
|
||||||
|
endpoint="https://push.example/device-a",
|
||||||
|
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await main.push_status(request) == {
|
||||||
|
"available": True, "subscribed": False, "public_key": "public-vapid"
|
||||||
|
}
|
||||||
|
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||||
|
assert (await main.push_status(request))["subscribed"] is True
|
||||||
|
assert await main.unsubscribe_push(request) == {"subscribed": False}
|
||||||
|
assert (await main.push_status(request))["subscribed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "_push_configuration",
|
||||||
|
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def management_id(_session):
|
||||||
|
return "device-a"
|
||||||
|
|
||||||
|
async def unavailable():
|
||||||
|
raise RuntimeError("Gitea unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||||
|
monkeypatch.setattr(main, "notifications", unavailable)
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||||
|
payload = main.PushSubscriptionPayload(
|
||||||
|
endpoint="https://push.example/device-a",
|
||||||
|
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(main.HTTPException) as raised:
|
||||||
|
await main.subscribe_push(payload, request)
|
||||||
|
|
||||||
|
assert raised.value.status_code == 503
|
||||||
|
assert store.is_subscribed("device-a") is False
|
||||||
|
|
@ -112,6 +112,14 @@ async function dispatchNotificationClick(route) {{
|
||||||
}});
|
}});
|
||||||
if (pending) await pending;
|
if (pending) await pending;
|
||||||
}}
|
}}
|
||||||
|
async function dispatchPush(payload) {{
|
||||||
|
let pending;
|
||||||
|
listeners.push({{
|
||||||
|
data: {{json: () => payload}},
|
||||||
|
waitUntil: promise => {{ pending = promise; }},
|
||||||
|
}});
|
||||||
|
if (pending) await pending;
|
||||||
|
}}
|
||||||
(async () => {{
|
(async () => {{
|
||||||
{scenario}
|
{scenario}
|
||||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
|
@ -373,6 +381,33 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||||
assert result["notificationClosed"] is True
|
assert result["notificationClosed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
await dispatchPush({
|
||||||
|
title:'New work update', body:'Tap to review it in Stackchain.',
|
||||||
|
tag:'stackchain-update-42', route:'#/my-work/update/42',
|
||||||
|
repository:'must-not-render',
|
||||||
|
});
|
||||||
|
await dispatchNotificationClick('#/my-work/update/42');
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["notifications"] == [{
|
||||||
|
"title": "New work update",
|
||||||
|
"options": {
|
||||||
|
"body": "Tap to review it in Stackchain.",
|
||||||
|
"tag": "stackchain-update-42",
|
||||||
|
"data": {"route": "#/my-work/update/42"},
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
assert result["opened"] == [
|
||||||
|
"https://forge.example/dashboard/#/my-work/update/42"
|
||||||
|
]
|
||||||
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
|
|
@ -470,6 +505,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/mobile-search-viewport.js",
|
"/dashboard/static/mobile-search-viewport.js",
|
||||||
"/dashboard/static/mobile-composer-viewport.js",
|
"/dashboard/static/mobile-composer-viewport.js",
|
||||||
"/dashboard/static/mention-composer.js",
|
"/dashboard/static/mention-composer.js",
|
||||||
|
"/dashboard/static/push-notifications.js",
|
||||||
"/dashboard/static/background-issue-sync.js",
|
"/dashboard/static/background-issue-sync.js",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user