Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32394fcd8f | ||
|
|
ea3194cdca | ||
|
|
85043b4af6 | ||
|
|
dc202bde11 | ||
|
|
c4ba78c8cb | ||
|
|
e35d7dea35 | ||
|
|
b81d695585 | ||
|
|
75da76c6f5 | ||
|
|
f78d69fba5 | ||
|
|
8ce64d2aa7 | ||
|
|
48f75cff05 | ||
|
|
58260d8a26 | ||
|
|
54ff44462e | ||
|
|
a87a5a4a90 | ||
|
|
c91194c283 | ||
|
|
5806d47b61 | ||
|
|
95f24a4e90 | ||
|
|
c016261b1a | ||
|
|
bbc3e10525 | ||
|
|
9737300d55 |
No files matched your search
@@ -199,6 +199,9 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# 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.
|
||||
# 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.
|
||||
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'
|
||||
@@ -271,7 +274,12 @@ session linked to the credential is revoked atomically; unrelated credentials an
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in.
|
||||
normally keeps the passkey available for the next sign-in. Passkey assertion counters
|
||||
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
|
||||
fail before a session or authorization grant is issued, while counterless authenticators
|
||||
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
|
||||
@@ -10,6 +10,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -557,8 +560,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work { margin:0; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.my-work-actions { display:none; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
@@ -593,11 +595,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.review-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
+91
-42
@@ -9,6 +9,7 @@
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
||||
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
||||
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
||||
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
|
||||
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
|
||||
],
|
||||
});
|
||||
@@ -36,7 +37,7 @@
|
||||
try {
|
||||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
console.warn('Could not persist panel state', e);
|
||||
console.warn('Panel state save failed', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,7 +56,7 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
function openMobileQueue(name) {
|
||||
function selectMobileQueue(name) {
|
||||
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
|
||||
renderAttentionInterruption();
|
||||
timerView.render();
|
||||
@@ -74,6 +75,12 @@
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
openFallback: openMobileWorkFallback,
|
||||
});
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const mobileTaskDock = createMobileTaskDock({
|
||||
nav: qs('#mobile-task-dock'),
|
||||
sessionHud: qs('[data-mobile-today-hud]'),
|
||||
@@ -88,7 +95,7 @@
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
|
||||
),
|
||||
queueBadge: qs('#mobile-queue-count'),
|
||||
onSelectQueue: openMobileQueue,
|
||||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name),
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => mobileWorkEntry.open(),
|
||||
@@ -122,7 +129,7 @@
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
console.warn('Could not restore My Work filter', e);
|
||||
console.warn('Filter restore failed', e);
|
||||
}
|
||||
let lastMyWork = [];
|
||||
let lastDrafts = [];
|
||||
@@ -779,6 +786,15 @@
|
||||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||||
},
|
||||
});
|
||||
const notificationUndo = createDashboardNotificationUndo({
|
||||
restore: requestNotificationUnread,
|
||||
getItems: () => lastMyWork,
|
||||
setItems: items => { lastMyWork = items; },
|
||||
getNotifications: () => lastNotifications,
|
||||
setNotifications: items => { lastNotifications = items; },
|
||||
refresh: refreshMyWorkView,
|
||||
select: qs,
|
||||
});
|
||||
const notificationReader = createNotificationReader({
|
||||
load: fetchNotificationDetail,
|
||||
loadConversation: fetchNotificationConversation,
|
||||
@@ -920,6 +936,7 @@
|
||||
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||
},
|
||||
onQueue: openWorkQueueRoute,
|
||||
onClose: () => {
|
||||
if (selectedIssue) closeIssueSheet(false);
|
||||
if (selectedPull) closePullSheet(false);
|
||||
@@ -942,7 +959,7 @@
|
||||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||
|
||||
function closeOpenWorkSheets() {
|
||||
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#update-sheet .update-sheet-panel']
|
||||
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
|
||||
.forEach(selector => mobileComposerViewport.close(qs(selector)));
|
||||
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
||||
qs(selector).classList.remove('open')
|
||||
@@ -1466,6 +1483,9 @@
|
||||
}
|
||||
|
||||
function closePlanToday(navigate = true) {
|
||||
planTodayReadiness.cancel();
|
||||
qs('#build-today-plan').disabled = false;
|
||||
qs('#plan-today-build-status').textContent = '';
|
||||
if (navigate) {
|
||||
taskOverlayHistory.close();
|
||||
return;
|
||||
@@ -1514,28 +1534,30 @@
|
||||
},
|
||||
});
|
||||
|
||||
const planTodayReadiness = createPlanTodayReadiness({
|
||||
concurrency:3,
|
||||
identity:item => todayWork.identity(item),
|
||||
inspect:async item => {
|
||||
if (item.kind !== 'issue') return { status:'ready' };
|
||||
const detail = await inspectTodayDependencies(item);
|
||||
if (!detail.available) return { status:'unverified' };
|
||||
if (detail.dependencies.length) return {
|
||||
status:'blocked', reason:detail.dependencies.length + ' open ' +
|
||||
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
|
||||
};
|
||||
return { status:'ready' };
|
||||
},
|
||||
});
|
||||
|
||||
async function buildTodayPlan() {
|
||||
const button = qs('#build-today-plan');
|
||||
const state = planToday.snapshot();
|
||||
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
|
||||
button.disabled = true;
|
||||
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
|
||||
const checks = await Promise.all(candidates.map(async item => {
|
||||
const id = todayWork.identity(item);
|
||||
if (item.kind !== 'issue') return [id, { status:'ready' }];
|
||||
try {
|
||||
const detail = await inspectTodayDependencies(item);
|
||||
if (!detail.available) return [id, { status:'unverified' }];
|
||||
if (detail.dependencies.length) return [id, {
|
||||
status:'blocked', reason:detail.dependencies.length + ' open ' +
|
||||
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
|
||||
}];
|
||||
return [id, { status:'ready' }];
|
||||
} catch (_error) {
|
||||
return [id, { status:'unverified' }];
|
||||
}
|
||||
}));
|
||||
planToday.buildRecommendation(Object.fromEntries(checks));
|
||||
const checks = await planTodayReadiness.run(candidates);
|
||||
if (!checks) return;
|
||||
planToday.buildRecommendation(checks);
|
||||
button.disabled = false;
|
||||
renderPlanToday();
|
||||
}
|
||||
@@ -3401,6 +3423,7 @@
|
||||
}
|
||||
|
||||
function closeInlineComposer() {
|
||||
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
|
||||
qs('#review-inline-composer').hidden = true;
|
||||
qs('#review-inline-body').value = '';
|
||||
activeInlineTarget = null;
|
||||
@@ -3534,6 +3557,7 @@
|
||||
workRoute.close();
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
|
||||
qs('#review-sheet').classList.remove('open');
|
||||
selectedReview = null;
|
||||
offlineReview = false;
|
||||
@@ -3670,7 +3694,6 @@
|
||||
} else {
|
||||
setEventStreamStatus('Update failed · showing last activity');
|
||||
}
|
||||
// Compatibility fallback for snapshots produced before section metadata.
|
||||
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
|
||||
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
|
||||
setEventStreamStatus('Refresh failed · showing last known data' +
|
||||
@@ -5019,6 +5042,9 @@
|
||||
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||
'Reply and read acknowledgement queued for sync.';
|
||||
if (result?.delivery === 'posted' && result?.next) {
|
||||
notificationUndo.offer(item, result.next.items);
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
qs('#update-reply').focus();
|
||||
@@ -5031,7 +5057,8 @@
|
||||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||||
qs('#mark-update-read-next').disabled = true;
|
||||
try {
|
||||
await notificationReader.markReadAndNext(lastMyWork);
|
||||
const result = await notificationReader.markReadAndNext(lastMyWork);
|
||||
if (result) notificationUndo.offer(result.item, result.items);
|
||||
} finally {
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
}
|
||||
@@ -5041,11 +5068,17 @@
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
||||
if (result) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.';
|
||||
if (result) notificationUndo.offer(result.item, result.items);
|
||||
} finally {
|
||||
button.disabled = offlineWorkMode;
|
||||
}
|
||||
});
|
||||
qs('#undo-notification').addEventListener('click', async () => {
|
||||
const button = qs('#undo-notification');
|
||||
button.disabled = true;
|
||||
const restored = await notificationUndo.run();
|
||||
button.disabled = restored;
|
||||
});
|
||||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||
qs('#retry-review-load').addEventListener('click', () => {
|
||||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
@@ -5507,25 +5540,41 @@
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||
button.addEventListener('click', () => {
|
||||
const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
|
||||
selectedWorkFilter = button.dataset.workFilter;
|
||||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||
savedWorkFilter = selectedWorkFilter;
|
||||
launchFilterResolved = true;
|
||||
try {
|
||||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||||
} catch (e) {
|
||||
console.warn('Could not persist My Work filter', e);
|
||||
}
|
||||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
);
|
||||
const selectedCount = button.querySelector('[data-work-count]').textContent;
|
||||
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
|
||||
renderMyWork();
|
||||
updateWorkPaginationControls();
|
||||
selectWorkQueue(button.dataset.workFilter);
|
||||
});
|
||||
});
|
||||
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
|
||||
const button = qs('[data-work-filter="' + filter + '"]');
|
||||
if (!button) return false;
|
||||
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
|
||||
selectedWorkFilter = filter;
|
||||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||
savedWorkFilter = selectedWorkFilter;
|
||||
launchFilterResolved = true;
|
||||
try {
|
||||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||||
} catch (e) {
|
||||
console.warn('Filter save failed', e);
|
||||
}
|
||||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
);
|
||||
const selectedCount = button.querySelector('[data-work-count]').textContent;
|
||||
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
|
||||
renderMyWork();
|
||||
updateWorkPaginationControls();
|
||||
if (!preserveRoute && filter === 'update' && window.location.hash !== '#/my-work/updates') {
|
||||
window.history.pushState({ workQueue:'update' }, '', '#/my-work/updates');
|
||||
} else if (!preserveRoute && filter !== 'update' && window.location.hash === '#/my-work/updates') {
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function openWorkQueueRoute(filter) {
|
||||
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
function openDeliveryReceiptRoute() {
|
||||
if (window.location.hash !== '#/my-work/drafts') return;
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
@@ -5537,7 +5586,7 @@
|
||||
qs('#work-milestone-filter').addEventListener('change', event => {
|
||||
selectedWorkMilestone = event.target.value;
|
||||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||||
catch (e) { console.warn('Could not persist My Work milestone lane', e); }
|
||||
catch (e) { console.warn('Milestone save failed', e); }
|
||||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
});
|
||||
|
||||
@@ -688,6 +688,11 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
|
||||
<span id="notification-undo-status"></span>
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
@@ -861,8 +866,10 @@
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
@@ -898,6 +905,7 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
@@ -907,6 +915,7 @@
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
@@ -932,6 +941,7 @@
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueueLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
|
||||
const emptyMessages = {
|
||||
attention: 'No items need attention.',
|
||||
update: 'No unread updates are ready to open.',
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
options.announce(emptyMessages[name] || 'No work is ready to open.');
|
||||
return 'empty';
|
||||
}
|
||||
action.click();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
return { open };
|
||||
});
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
if (options.queueSheet?.open) options.queueSheet.close();
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
@@ -42,8 +42,7 @@
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
else options.actions[name]();
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
@@ -56,7 +55,7 @@
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name);
|
||||
options.onSelectQueue?.(name, row);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -95,17 +94,20 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = ['today', 'attention', 'later', 'draft'];
|
||||
const names = 'today attention update later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = names.reduce((sum, name) => sum + normalized[name], 0);
|
||||
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
|
||||
+1
-1
@@ -362,7 +362,7 @@ function createNotificationReader({
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return { items: updated, item: current, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||
let offered = null;
|
||||
let pending = false;
|
||||
|
||||
return {
|
||||
offer(item, items) {
|
||||
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||
const label = item.key || 'Update';
|
||||
onStatus(label + ' marked read. Undo?');
|
||||
onOffer(item);
|
||||
return true;
|
||||
},
|
||||
async run() {
|
||||
if (!offered || pending) return false;
|
||||
const current = offered;
|
||||
const label = current.item.key || 'Update';
|
||||
pending = true;
|
||||
onStatus('Restoring ' + label + '…');
|
||||
try {
|
||||
await restore(current.item.notification_id);
|
||||
if (offered !== current) return false;
|
||||
const restored = [current.item].concat(current.items.filter(
|
||||
item => item?.notification_id !== current.item.notification_id
|
||||
));
|
||||
offered = null;
|
||||
onItems(restored, current.item);
|
||||
onStatus(label + ' is unread again.');
|
||||
onClear();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
offered = null;
|
||||
onClear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNotificationUnread(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||
let timer = null;
|
||||
const controller = createNotificationUndo({
|
||||
restore,
|
||||
onItems: (items, restored) => {
|
||||
setItems(items);
|
||||
const notifications = getNotifications();
|
||||
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onStatus: message => {
|
||||
select('#notification-undo-status').textContent = message;
|
||||
select('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onOffer: item => {
|
||||
clearTimeout(timer);
|
||||
select('#notification-undo').hidden = false;
|
||||
select('#undo-notification').disabled = false;
|
||||
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||
timer = setTimeout(() => controller.clear(), 10000);
|
||||
},
|
||||
onClear: () => { select('#notification-undo').hidden = true; },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createNotificationUndo;
|
||||
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
|
||||
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
|
||||
let generation = 0;
|
||||
|
||||
async function run(items) {
|
||||
const runGeneration = ++generation;
|
||||
const candidates = Array.from(items || []);
|
||||
const results = new Array(candidates.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < candidates.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const item = candidates[index];
|
||||
let result;
|
||||
try {
|
||||
result = await inspect(item);
|
||||
} catch (_error) {
|
||||
result = { status:'unverified' };
|
||||
}
|
||||
results[index] = [identity(item), result];
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length:Math.min(workerCount, candidates.length) },
|
||||
() => worker(),
|
||||
));
|
||||
if (runGeneration !== generation) return null;
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
return { run, cancel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
|
||||
@@ -128,6 +128,7 @@
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
@@ -141,6 +142,9 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.kind === 'passkey_counter_anomaly'
|
||||
? 'Remove and re-enroll this passkey if the alert repeats'
|
||||
: null,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v94';
|
||||
const CACHE = 'stackchain-dashboard-shell-v96';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -32,6 +32,7 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
@@ -41,6 +42,7 @@ const SHELL = [
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
@@ -66,6 +68,7 @@ const SHELL = [
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
if (parts[2] === 'updates' && parts.length === 3) {
|
||||
return { kind: 'queue', filter: 'update' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
@@ -38,6 +41,7 @@
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'queue') return false;
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
@@ -48,6 +52,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -100,6 +105,16 @@
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'queue') {
|
||||
if (!ready) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
|
||||
@@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
||||
),
|
||||
|
||||
@@ -804,6 +804,14 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def mark_notification_unread(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
|
||||
+48
-2
@@ -48,6 +48,7 @@ from src.live_snapshot_store import (
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
@@ -1595,9 +1596,19 @@ async def verify_passkey_authorization(
|
||||
stored=stored,
|
||||
)
|
||||
updated = await asyncio.to_thread(
|
||||
store.update_counter, stored.credential_id, verified.new_sign_count
|
||||
store.advance_counter,
|
||||
stored.credential_id,
|
||||
expected=stored.sign_count,
|
||||
new=verified.new_sign_count,
|
||||
)
|
||||
if not updated:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target=f"{payload.action}:{payload.target}",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
grant = await dashboard_auth.issue_step_up(
|
||||
request.state.dashboard_session,
|
||||
@@ -1686,9 +1697,19 @@ async def verify_passkey_authentication(
|
||||
stored=stored,
|
||||
)
|
||||
updated = await asyncio.to_thread(
|
||||
store.update_counter, stored.credential_id, verified.new_sign_count
|
||||
store.advance_counter,
|
||||
stored.credential_id,
|
||||
expected=stored.sign_count,
|
||||
new=verified.new_sign_count,
|
||||
)
|
||||
if not updated:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="sign_in:dashboard",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
await dashboard_auth.revoke_managed_session(stored.management_id)
|
||||
signed, session = await asyncio.to_thread(
|
||||
@@ -1788,6 +1809,10 @@ async def push_status(request: Request):
|
||||
async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
|
||||
if not _push_configuration().enabled:
|
||||
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||
try:
|
||||
await validate_public_push_endpoint(payload.endpoint)
|
||||
except UnsafePushEndpoint as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
@@ -3432,6 +3457,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/unread")
|
||||
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
gitea_proxy.mark_notification_unread(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Restoring the update timed out. Retry Undo."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The update could not be restored. Retry Undo."},
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse({"id": thread_id, "status": "unread"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
||||
async def defer_notification(
|
||||
payload: NotificationLaterRequest,
|
||||
|
||||
+17
-4
@@ -225,13 +225,26 @@ class PasskeyStore:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
|
||||
def advance_counter(
|
||||
self, credential_id: bytes, *, expected: int, new: int
|
||||
) -> bool:
|
||||
if expected == 0 and new == 0:
|
||||
comparison = "sign_count = 0"
|
||||
elif new > expected:
|
||||
comparison = "sign_count = ?"
|
||||
else:
|
||||
return False
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
parameters = (
|
||||
(new, credential_id)
|
||||
if expected == 0 and new == 0
|
||||
else (new, credential_id, expected)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"UPDATE passkey_credentials SET sign_count = ? "
|
||||
"WHERE credential_id = ? AND sign_count <= ?",
|
||||
(new_sign_count, credential_id, new_sign_count),
|
||||
f"UPDATE passkey_credentials SET sign_count = ? "
|
||||
f"WHERE credential_id = ? AND {comparison}",
|
||||
parameters,
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafePushEndpoint(ValueError):
|
||||
"""Raised when a Web Push endpoint could reach a non-public service."""
|
||||
|
||||
|
||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||
|
||||
|
||||
async def _resolve(host: str, port: int) -> list[str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
records = await loop.getaddrinfo(
|
||||
host,
|
||||
port,
|
||||
family=socket.AF_UNSPEC,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
return sorted({record[4][0] for record in records})
|
||||
|
||||
|
||||
async def validate_public_push_endpoint(
|
||||
endpoint: str,
|
||||
*,
|
||||
resolver: Resolver = _resolve,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> str:
|
||||
"""Return a canonical public HTTPS push endpoint or fail closed."""
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint(
|
||||
"Endpoint must be a canonical public Web Push service URL"
|
||||
) from error
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or port not in {None, 443}
|
||||
):
|
||||
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
|
||||
try:
|
||||
addresses = await asyncio.wait_for(
|
||||
resolver(parsed.hostname, port or 443),
|
||||
timeout=max(0.01, timeout_seconds),
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
try:
|
||||
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
if not public:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
return endpoint
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,8 +23,12 @@ class PushConfiguration:
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
) -> None:
|
||||
import requests
|
||||
from pywebpush import webpush
|
||||
|
||||
session = requests.Session()
|
||||
session.max_redirects = 0
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
subscription_info=subscription,
|
||||
@@ -31,6 +36,8 @@ async def send_web_push(
|
||||
vapid_private_key=configuration.private_key,
|
||||
vapid_claims={"sub": configuration.subject},
|
||||
ttl=300,
|
||||
timeout=10,
|
||||
requests_session=session,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +52,7 @@ async def dispatch_unread_updates(
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
@@ -90,6 +98,15 @@ async def dispatch_unread_updates(
|
||||
async with semaphore:
|
||||
if ownership_lost.is_set():
|
||||
return 0
|
||||
validate_endpoint = endpoint_validator
|
||||
if validate_endpoint is None and send is None:
|
||||
validate_endpoint = validate_public_push_endpoint
|
||||
try:
|
||||
if validate_endpoint is not None:
|
||||
await validate_endpoint(delivery.subscription["endpoint"])
|
||||
except UnsafePushEndpoint:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
return 0
|
||||
count = 0
|
||||
digest_pending = set(delivery.digest_revisions)
|
||||
new_revisions = tuple(
|
||||
@@ -202,8 +219,17 @@ async def dispatch_unread_updates(
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
*(dispatch_device(delivery) for delivery in deliveries)
|
||||
*(dispatch_device_safely(delivery) for delivery in deliveries)
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v94" in worker
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
@@ -170,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert denied.status_code == 401
|
||||
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||
assert events[0].device_label == "Phone"
|
||||
assert events[0].target == "sign_in:dashboard"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
|
||||
headers=headers,
|
||||
)
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/dashboard#42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert options.status_code == 200
|
||||
assert denied.status_code == 401
|
||||
assert "grant" not in denied.json()
|
||||
assert events[0].kind == "passkey_counter_anomaly"
|
||||
assert events[0].target == "merge_pull:stackchain/dashboard#42"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_options_are_source_limited_before_challenge_generation(
|
||||
access_control, monkeypatch
|
||||
|
||||
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v94';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v96';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v94" in worker
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
@@ -20,6 +20,7 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
("#review-sheet .review-sheet-panel", "#review-inline-composer", "#review-inline-body", "#save-inline-comment", "#review-submit-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
@@ -27,10 +28,13 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
assert "workspace:qs('#create-issue-form')" in dashboard
|
||||
assert "focusWithin:true" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'))" in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
assert ".create-issue-panel.composer-keyboard-active" in css
|
||||
assert ".review-sheet-panel.composer-keyboard-active" in css
|
||||
assert ".composer-keyboard-active .review-inline-composer" in css
|
||||
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
|
||||
|
||||
|
||||
@@ -41,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v94" in worker
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v94" in worker
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -69,4 +69,4 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
|
||||
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
|
||||
assert ".my-work-actions button { min-height:44px;" in html
|
||||
assert ".my-work-actions { display:none; }" in html
|
||||
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
@@ -292,26 +293,30 @@ sheet.showModal = function () {{ this.open = true; }};
|
||||
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()]));
|
||||
const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
|
||||
onSelectQueue:name=>selected.push(name),
|
||||
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, attention:1, later:3, draft:4}});
|
||||
dock.updateQueues({{today:2, attention:1, update:5, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.later.click();
|
||||
rows.update.click();
|
||||
const closedAfterSelect = !sheet.open;
|
||||
queues.click(); rows.recaps.click();
|
||||
const closedAfterUtility = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, selected,
|
||||
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
|
||||
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
|
||||
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
|
||||
updateLabel:rows.update.attributes['aria-label'],
|
||||
queueFocuses:queues.focuses,
|
||||
columnState:nav.attributes['data-attention'] || null,
|
||||
}}));
|
||||
@@ -322,15 +327,47 @@ process.stdout.write(JSON.stringify({{
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"closedAfterSelect": True,
|
||||
"selected": ["later"],
|
||||
"closedAfterUtility": True,
|
||||
"selected": ["update"],
|
||||
"utilities": ["recaps:trigger"],
|
||||
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
|
||||
"badge": "10",
|
||||
"badgeLabel": "Queues, 10 items",
|
||||
"counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"},
|
||||
"queueFocuses": 2,
|
||||
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"},
|
||||
"updateLabel": "Updates, 5 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
const launcher = createLauncher({{
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => name === 'attention' ? {{click() {{ calls.push('open:first'); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
}});
|
||||
const opened = launcher.open('attention');
|
||||
const empty = launcher.open('later');
|
||||
process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": "opened",
|
||||
"empty": "empty",
|
||||
"calls": [
|
||||
"filter:attention",
|
||||
"open:first",
|
||||
"filter:later",
|
||||
"announce:No deferred work is ready to open.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
@@ -340,12 +377,20 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert 'aria-labelledby="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="today"' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'data-mobile-queue="recaps"' in html
|
||||
assert '<strong>Recaps</strong><small>History</small>' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'mobileTaskDock.updateQueues(counts)' in html
|
||||
assert "onSelectQueue: openMobileQueue" in html
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"]' not in html
|
||||
|
||||
@@ -389,7 +434,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "onSelectQueue: openMobileQueue" in html
|
||||
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
|
||||
+103
-1
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
@@ -395,6 +396,49 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
||||
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
|
||||
|
||||
|
||||
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/updates'}};
|
||||
const calls = [];
|
||||
const stack = ['#/my-work/updates'];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
|
||||
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
|
||||
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
||||
}};
|
||||
const controller = routes.createController({{
|
||||
location, history,
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onQueue: queue => calls.push(['queue', queue]),
|
||||
onOpen: item => calls.push(['open', item.notification_id]),
|
||||
onClose: () => calls.push(['close']),
|
||||
onInvalid: () => calls.push(['invalid']),
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([]);
|
||||
controller.open({{kind:'update', notification_id:42}});
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
["queue", "update"],
|
||||
["open", 42],
|
||||
["close"],
|
||||
["queue", "update"],
|
||||
],
|
||||
"hash": "#/my-work/updates",
|
||||
"parsed": {"kind": "queue", "filter": "update"},
|
||||
}
|
||||
|
||||
|
||||
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
@@ -507,6 +551,10 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
|
||||
assert html.count('class="share-work-route"') == 4
|
||||
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
|
||||
assert 'Route unavailable · this item is no longer in My Work.' in html
|
||||
assert "onQueue: openWorkQueueRoute" in html
|
||||
assert "selectWorkQueue(filter, { preserveRoute:true })" in html
|
||||
assert "filter === 'update' && window.location.hash !== '#/my-work/updates'" in html
|
||||
assert "window.history.pushState({ workQueue:'update' }, '', '#/my-work/updates')" in html
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
@@ -4076,6 +4124,58 @@ const reader = buildMyWork.createNotificationReader({{
|
||||
assert output["results"][1] is False
|
||||
|
||||
|
||||
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
|
||||
script = f"""
|
||||
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const calls = [];
|
||||
let attempts = 0;
|
||||
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
|
||||
const undo = createNotificationUndo({{
|
||||
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
|
||||
onItems: items => states.push(items.map(value => value.notification_id)),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
(async () => {{
|
||||
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
|
||||
const first = undo.run();
|
||||
const duplicate = undo.run();
|
||||
const firstResults = await Promise.all([first, duplicate]);
|
||||
const retry = await undo.run();
|
||||
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [42, 42]
|
||||
assert output["states"] == [[42, 43]]
|
||||
assert output["firstResults"] == [False, False]
|
||||
assert output["retry"] is True
|
||||
assert output["statuses"] == [
|
||||
"repo#7 marked read. Undo?",
|
||||
"Restoring repo#7…",
|
||||
"Could not restore repo#7. Retry Undo.",
|
||||
"Restoring repo#7…",
|
||||
"repo#7 is unread again.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="notification-undo"' in html
|
||||
assert 'id="undo-notification" type="button"' in html
|
||||
assert "requestNotificationUnread" in html
|
||||
assert "createDashboardNotificationUndo({" in html
|
||||
assert ".notification-undo { position:fixed;" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
assert ".notification-undo button { min-height:44px;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
||||
html = await dashboard()
|
||||
@@ -4354,7 +4454,9 @@ reader.open(item, [item]).then(() => {{
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["result"] == {"items": [], "next": None}
|
||||
assert output["result"] == {"items": [], "item": {
|
||||
"kind": "update", "notification_id": 42, "has_update": True,
|
||||
}, "next": None}
|
||||
assert output["events"] == [
|
||||
["status", "Marking update read…"],
|
||||
["items", []],
|
||||
|
||||
@@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
class Client:
|
||||
async def patch(self, path, headers):
|
||||
calls.append((path, headers))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
await gitea_proxy.mark_notification_unread(42)
|
||||
|
||||
assert calls == [
|
||||
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
||||
calls = []
|
||||
@@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
restored = []
|
||||
|
||||
async def mark_unread(thread_id):
|
||||
restored.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/notifications/42/unread")
|
||||
invalid = await client.patch("/api/v1/notifications/0/unread")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "status": "unread"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert restored == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -104,3 +104,44 @@ def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(
|
||||
).fetchall()
|
||||
assert "source_hash" in columns
|
||||
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
|
||||
|
||||
|
||||
def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
first = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
second = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
first.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
|
||||
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
|
||||
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
|
||||
assert first.get(b"phone-credential").sign_count == 5
|
||||
|
||||
|
||||
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
|
||||
tmp_path,
|
||||
):
|
||||
store = PasskeyStore(tmp_path / "passkeys.sqlite3", clock=lambda: 1_000.0)
|
||||
store.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
store.register(
|
||||
credential_id=b"counterless-credential",
|
||||
public_key=b"counterless-public-key",
|
||||
sign_count=0,
|
||||
device_label="Security key",
|
||||
management_id="counterless-management-id",
|
||||
)
|
||||
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=3) is False
|
||||
assert store.advance_counter(b"counterless-credential", expected=0, new=0) is True
|
||||
@@ -410,6 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
READINESS = Path(__file__).parents[1] / "frontend" / "plan-today-readiness.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_plan_readiness_bounds_concurrency_and_preserves_ranked_order():
|
||||
script = f"""
|
||||
const createReadiness = require({json.dumps(str(READINESS))});
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const completed = [];
|
||||
const delays = {{1:40, 2:5, 3:20, 4:1, 5:10, 6:2}};
|
||||
const readiness = createReadiness({{
|
||||
concurrency:3,
|
||||
identity:item => 'issue:' + item.number,
|
||||
inspect:async item => {{
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise(resolve => setTimeout(resolve, delays[item.number]));
|
||||
completed.push(item.number);
|
||||
active -= 1;
|
||||
return item.number === 4 ? {{status:'blocked', reason:'Waiting'}} : {{status:'ready'}};
|
||||
}},
|
||||
}});
|
||||
readiness.run([1,2,3,4,5,6].map(number => ({{kind:'issue', number}}))).then(result => {{
|
||||
process.stdout.write(JSON.stringify({{peak, completed, entries:Object.entries(result)}}));
|
||||
}});
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["peak"] == 3
|
||||
assert result["completed"] != [1, 2, 3, 4, 5, 6]
|
||||
assert result["entries"] == [
|
||||
["issue:1", {"status": "ready"}],
|
||||
["issue:2", {"status": "ready"}],
|
||||
["issue:3", {"status": "ready"}],
|
||||
["issue:4", {"status": "blocked", "reason": "Waiting"}],
|
||||
["issue:5", {"status": "ready"}],
|
||||
["issue:6", {"status": "ready"}],
|
||||
]
|
||||
|
||||
|
||||
def test_plan_readiness_supersedes_stale_builds_and_normalizes_real_failures():
|
||||
script = f"""
|
||||
const createReadiness = require({json.dumps(str(READINESS))});
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise(resolve => {{ releaseFirst = resolve; }});
|
||||
const readiness = createReadiness({{
|
||||
concurrency:2,
|
||||
identity:item => 'issue:' + item.number,
|
||||
inspect:async item => {{
|
||||
if (item.number === 1) await firstGate;
|
||||
if (item.number === 3) throw new Error('upstream details');
|
||||
return {{status:'ready'}};
|
||||
}},
|
||||
}});
|
||||
const stale = readiness.run([{{number:1}}, {{number:2}}]);
|
||||
const current = readiness.run([{{number:3}}, {{number:4}}]);
|
||||
current.then(currentResult => {{
|
||||
releaseFirst();
|
||||
stale.then(staleResult => process.stdout.write(JSON.stringify({{staleResult, currentResult}})));
|
||||
}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"staleResult": None,
|
||||
"currentResult": {
|
||||
"issue:3": {"status": "unverified"},
|
||||
"issue:4": {"status": "ready"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_uses_bounded_supersedable_readiness_for_build_my_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/plan-today-readiness.js"></script>' in html
|
||||
assert "const planTodayReadiness = createPlanTodayReadiness({" in html
|
||||
assert "concurrency:3" in html
|
||||
assert "const checks = await planTodayReadiness.run(candidates);" in html
|
||||
assert "if (!checks) return;" in html
|
||||
assert "planTodayReadiness.cancel();" in html
|
||||
assert "Promise.all(candidates.map" not in html
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["127.0.0.1", "::1", "10.0.0.4", "169.254.169.254", "fc00::1"],
|
||||
)
|
||||
async def test_push_endpoint_rejects_every_non_public_resolved_address(address):
|
||||
async def resolve(_host, _port):
|
||||
return [address]
|
||||
|
||||
with pytest.raises(UnsafePushEndpoint, match="public Web Push service"):
|
||||
await validate_public_push_endpoint(
|
||||
"https://push.example/device", resolver=resolve
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint",
|
||||
[
|
||||
"http://push.example/device",
|
||||
"https://user@push.example/device",
|
||||
"https://push.example:8443/device",
|
||||
"https://push.example:not-a-port/device",
|
||||
"https://push.example/device#fragment",
|
||||
],
|
||||
)
|
||||
async def test_push_endpoint_rejects_noncanonical_authorities_without_resolving(endpoint):
|
||||
async def must_not_resolve(_host, _port):
|
||||
raise AssertionError("invalid URL reached DNS")
|
||||
|
||||
with pytest.raises(UnsafePushEndpoint, match="canonical public Web Push"):
|
||||
await validate_public_push_endpoint(endpoint, resolver=must_not_resolve)
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
@@ -10,6 +11,15 @@ import pytest
|
||||
from src import dashboard_auth, gitea_proxy, main
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def resolve_test_push_endpoints_publicly(monkeypatch):
|
||||
async def accept(endpoint):
|
||||
return endpoint
|
||||
|
||||
monkeypatch.setattr(main, "validate_public_push_endpoint", accept)
|
||||
|
||||
|
||||
def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
|
||||
@@ -508,6 +518,32 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||
assert store.is_subscribed("session-a") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_removes_an_endpoint_that_rebinds_private_without_contacting_it(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("unsafe-device", {
|
||||
"endpoint": "https://push.example/rebound",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
contacted = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8}]}
|
||||
|
||||
async def reject_rebound(_endpoint):
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
|
||||
async def send(subscription, _payload):
|
||||
contacted.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store, config, unread, send, endpoint_validator=reject_rebound
|
||||
) == 0
|
||||
assert contacted == []
|
||||
assert store.is_subscribed("unsafe-device") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -532,6 +568,75 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
|
||||
assert store.claim_unseen({8})[0].session_id == "session-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
store = PushSubscriptionStore(path)
|
||||
competitor = PushSubscriptionStore(path)
|
||||
for session_id in ("failing-device", "healthy-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
healthy_started = asyncio.Event()
|
||||
release_healthy = asyncio.Event()
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8, "updated_at": "r1"}], "complete": True}
|
||||
|
||||
async def validate(endpoint):
|
||||
if endpoint.endswith("failing-device"):
|
||||
raise TimeoutError("endpoint validation unavailable")
|
||||
return endpoint
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
healthy_started.set()
|
||||
await release_healthy.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
dispatch = asyncio.create_task(dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
send,
|
||||
endpoint_validator=validate,
|
||||
))
|
||||
await asyncio.wait_for(healthy_started.wait(), timeout=0.2)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert dispatch.done() is False
|
||||
assert competitor.acquire_dispatch_lease(
|
||||
"competing-worker", now=time.time(), lease_seconds=60
|
||||
) is False
|
||||
|
||||
release_healthy.set()
|
||||
assert await dispatch == 1
|
||||
remaining = store.claim_unseen({8: "r1"})
|
||||
assert [(item.session_id, item.thread_ids) for item in remaining] == [
|
||||
("failing-device", (8,))
|
||||
]
|
||||
|
||||
retried = []
|
||||
|
||||
async def validate_recovered(endpoint):
|
||||
return endpoint
|
||||
|
||||
async def send_retry(subscription, _payload):
|
||||
retried.append(subscription["endpoint"])
|
||||
|
||||
assert await dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
send_retry,
|
||||
endpoint_validator=validate_recovered,
|
||||
) == 1
|
||||
assert retried == ["https://push.example/failing-device"]
|
||||
assert sent == ["https://push.example/healthy-device"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -722,6 +827,37 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(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 "unsafe-device"
|
||||
|
||||
async def reject_private(_endpoint):
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "validate_public_push_endpoint", reject_private)
|
||||
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 rejected:
|
||||
await main.subscribe_push(payload, request)
|
||||
|
||||
assert rejected.value.status_code == 422
|
||||
assert rejected.value.detail == "Endpoint must resolve to a public Web Push service"
|
||||
assert store.is_subscribed("unsafe-device") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_baselines_every_revision_from_the_complete_snapshot(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -47,3 +47,42 @@ const boundary={{
|
||||
"hidden": False,
|
||||
"focused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_counter_anomaly_activity_gives_passkey_remediation_guidance():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const element=()=>({{
|
||||
hidden:true, disabled:false, textContent:'', children:[], className:'',
|
||||
addEventListener(){{}}, focus(){{}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},
|
||||
}});
|
||||
const ids={{
|
||||
'active-devices':element(), 'active-devices-sheet':element(),
|
||||
'active-devices-list':element(), 'active-devices-status':element(),
|
||||
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
|
||||
'enroll-passkey':element(), 'security-activity-list':element(),
|
||||
'security-activity-status':element(), 'load-more-security-activity':element(),
|
||||
'close-active-devices':element(),
|
||||
}};
|
||||
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>[], listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>({{events:[{{
|
||||
kind:'passkey_counter_anomaly', device_label:'Phone', method:'passkey',
|
||||
target:'sign_in:dashboard', status:'completed', created_at:1,
|
||||
}}],authentication_alerts:[],next_cursor:null}}),
|
||||
}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
const row=ids['security-activity-list'].children[0];
|
||||
console.log(JSON.stringify(row.children.map(child=>child.textContent)));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
title, detail = json.loads(completed.stdout)
|
||||
assert title == "Passkey counter anomaly"
|
||||
assert "Remove and re-enroll this passkey" in detail
|
||||
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -654,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -695,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/notification-undo.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
@@ -704,6 +705,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/update-reply-read-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-readiness.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
@@ -729,6 +731,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v94';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v96';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v94" in source
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user