Compare commits

...
Author SHA1 Message Date
timmy 48f75cff05 feat: launch work directly from mobile queues (Closes #645)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:26:35 +00:00
timmy 58260d8a26 Merge pull request 'Keep mobile inline review above the keyboard' (#644) from timmy/mobile-review-keyboard-safe into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 10:56:08 +00:00
timmy 54ff44462e fix: keep mobile inline review above keyboard (Closes #643)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:53:56 +00:00
timmy a87a5a4a90 Merge pull request 'Detect and surface atomic passkey counter anomalies' (#642) from timmy/641-passkey-counter-anomalies into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 10:29:03 +00:00
timmy c91194c283 fix: detect atomic passkey counter anomalies (Closes #641)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:26:51 +00:00
timmy 5806d47b61 Merge pull request 'Expose unread Updates in the mobile queue switcher' (#640) from timmy/639-mobile-updates-queue into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 09:54:56 +00:00
timmy 95f24a4e90 feat: expose Updates in mobile queues (Closes #639)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:52:41 +00:00
timmy c016261b1a Merge pull request 'Bound Build my Today readiness checks' (#638)
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #637
2026-08-12 09:28:47 +00:00
timmy bbc3e10525 perf: bound Build my Today readiness checks (Closes #637)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:26:31 +00:00
timmy 9737300d55 Merge pull request 'Complete Build my Today with inline missing estimates' (#636) from timmy/635-inline-plan-estimates into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 08:58:11 +00:00
26 changed files with 503 additions and 55 deletions

No files matched your search

+6 -1
View File
@@ -271,7 +271,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
+2
View File
@@ -593,11 +593,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; }
+33 -19
View File
@@ -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 },
],
});
@@ -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 => mobileQueueLauncher.open(name),
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
@@ -942,7 +949,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 +1473,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 +1524,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 +3413,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 +3547,7 @@
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
+3
View File
@@ -861,6 +861,7 @@
<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>
</div>
@@ -907,6 +908,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 +934,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>
+25
View File
@@ -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 };
});
+7 -2
View File
@@ -95,12 +95,17 @@
}
function updateQueues(counts) {
const names = ['today', 'attention', 'later', 'draft'];
const names = ['today', 'attention', 'update', 'later', 'draft'];
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);
// Updates are already represented by the Attention destination, so keep
// the dock aggregate non-overlapping while exposing the useful drill-down.
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;
+41
View File
@@ -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;
+4
View File
@@ -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 : ''}`;
+3 -1
View File
@@ -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;
@@ -41,6 +41,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 +67,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',
+22 -2
View File
@@ -1595,9 +1595,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 +1696,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(
+17 -4
View File
@@ -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:
+1 -1
View File
@@ -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
+96
View File
@@ -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
+1 -1
View File
@@ -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';",
)
)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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():
+1 -1
View File
@@ -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
+44 -7
View File
@@ -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,7 +293,7 @@ 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'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const dock = createDock({{
@@ -302,16 +303,17 @@ const dock = createDock({{
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(); close.click();
process.stdout.write(JSON.stringify({{
opened, closedAfterSelect, selected,
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 +324,45 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"opened": True,
"closedAfterSelect": True,
"selected": ["later"],
"selected": ["update"],
# 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"},
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4"},
"updateLabel": "Updates, 5 unread conversations",
"queueFocuses": 2,
"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,11 +372,16 @@ 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 '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 => mobileQueueLauncher.open(name)" in html
assert '.mobile-queue-sheet' 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 +426,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 => 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
+41
View File
@@ -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
+2 -1
View File
@@ -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
+94
View File
@@ -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
+39
View File
@@ -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
+12 -10
View File
@@ -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
@@ -704,6 +704,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 +730,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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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