Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb7a78cab | ||
|
|
b4a5401b22 | ||
|
|
870edda123 | ||
|
|
156d4b0ffc | ||
|
|
304e4a8a24 | ||
|
|
8731682090 | ||
|
|
348f43f1bc | ||
|
|
161563d6f5 | ||
|
|
931c82361b | ||
|
|
61b332e814 | ||
|
|
4b3e53f4d0 | ||
|
|
f3c9e89920 | ||
|
|
1248647aa4 | ||
|
|
b3832d107f | ||
|
|
a0da8e9fb2 | ||
|
|
8794200c2a | ||
|
|
c2526969b1 | ||
|
|
3b4f6b39a5 | ||
|
|
a9cf88b3ff | ||
|
|
3d4be0a913 | ||
|
|
9252807364 | ||
|
|
db85bfa5f9 | ||
|
|
3822ec4536 | ||
|
|
b278367e3b | ||
|
|
13e73bd342 | ||
|
|
1047f3ecdc | ||
|
|
f09c922c46 | ||
|
|
be9ce8ede6 | ||
|
|
7f2681c1c6 | ||
|
|
91fdd958c2 | ||
|
|
b70a2fb607 | ||
|
|
950d52d147 | ||
|
|
d054cd23ed | ||
|
|
f4d8d64b9d | ||
|
|
9763bb410b | ||
|
|
db3ca83f29 | ||
|
|
c219f130c8 | ||
|
|
dc4f457b21 | ||
|
|
9a471b7903 |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -193,11 +193,18 @@ filesystem boundary independently of the service umask: the database directory i
|
||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
|
||||
envelopes authenticated to their store identity (and live generation), so copied databases do not
|
||||
expose issue bodies, titles, notification metadata, or repository context. Existing plaintext
|
||||
snapshot rows migrate on their first read without changing freshness, revisions, ordering, or claim
|
||||
filters. Synchronized unfiled Draft collections use a separate AES-256-GCM key and authenticate the
|
||||
account and revision; existing plaintext rows likewise migrate on first read. Other private stores
|
||||
are not encrypted at the application layer. Web Push subscriptions use a third, independent
|
||||
expose issue bodies, titles, notification metadata, or repository context. The authored-action
|
||||
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
|
||||
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
|
||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
|
||||
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||
are not encrypted at the application layer. Web
|
||||
Push subscriptions use a third, independent
|
||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
|
||||
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
|
||||
@@ -267,10 +274,13 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Required for worker-shared live and Find Work snapshots. Keep this key
|
||||
# independent from the Draft key and inject the base64 encoding of exactly 32
|
||||
# random bytes from a secret manager. Never commit it. Missing, malformed,
|
||||
# wrong-key, or modified snapshot state fails closed without returning content.
|
||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||
# planning and Saved Search state, and the Security activity journal. Keep this key independent
|
||||
# from the Draft key and inject the
|
||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||
@@ -384,8 +394,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||
retains at most 10,000 events for 90 days. Its bounded event kind, authentication
|
||||
method, device label, and action target are sealed at rest with the private-state
|
||||
encryption key and authenticated against the immutable event ID. Legacy plaintext
|
||||
journals migrate atomically without changing IDs, timestamps, order, or pending
|
||||
operations. Missing or wrong key material and modified ciphertext fail closed with
|
||||
no partial activity response. The journal never stores access tokens, cookies,
|
||||
session/CSRF values,
|
||||
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||
writable storage as the session registry. Before passkey credential creation, session
|
||||
@@ -453,6 +468,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Device Setup also checks whether the browser has granted persistent storage without prompting.
|
||||
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
|
||||
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
|
||||
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
|
||||
remove best-effort data, so persistence does not replace backups or device security.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
|
||||
+26
-1
@@ -6,12 +6,22 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.sign-out-review-sheet[hidden] { display:none; }
|
||||
.sign-out-review-panel { box-sizing:border-box; width:min(560px,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; overflow-wrap:anywhere; }
|
||||
.sign-out-review-panel h2 { margin:.25rem 0; }
|
||||
.sign-out-review-warning { color:#fbbf24; }
|
||||
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.sign-out-review-actions button { min-height:44px; width:100%; }
|
||||
#confirm-sign-out { border-color:#b45309; }
|
||||
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
|
||||
.live-data-status-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); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; }
|
||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.issue-filing-receipt[hidden] { display:none; }
|
||||
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
@@ -367,8 +377,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
||||
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
||||
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
||||
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
|
||||
.today-summary-destination h3 { margin-top:0; }
|
||||
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
|
||||
.today-summary-target { min-height:1.4em; margin-top:8px; }
|
||||
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-summary-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
|
||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||
@@ -1153,6 +1169,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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; }
|
||||
|
||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; }
|
||||
.mobile-first-task-actions { display:grid; gap:10px; }
|
||||
.mobile-first-task-actions button { min-height:48px; width:100%; }
|
||||
.mobile-first-task-actions button:disabled { opacity:.55; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
|
||||
+29
-20
@@ -140,6 +140,12 @@
|
||||
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
||||
qs('#mobile-start-day-action').focus();
|
||||
}
|
||||
const mobileFirstTask = createMobileFirstTask({
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
});
|
||||
mobileFirstTask.start();
|
||||
const mobileWorkEntry = createMobileWorkEntry({
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
@@ -152,6 +158,8 @@
|
||||
startToday: startTodaySession,
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
prepareToday: openMobileStartDay,
|
||||
shouldActivate: () => mobileFirstTask.required(),
|
||||
openActivation: () => mobileFirstTask.open(),
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
const mobileStartDay = createMobileStartDay({
|
||||
@@ -1929,7 +1937,10 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
|
||||
const todaySummaryView = setupTodaySummary({
|
||||
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
|
||||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
todaySummaryView.resume();
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||
@@ -3222,6 +3233,7 @@
|
||||
});
|
||||
mobileStartDay.render();
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileFirstTask.refresh();
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
@@ -7448,24 +7460,7 @@
|
||||
'Next automatic retry in ' + retrySeconds + 's.' :
|
||||
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
|
||||
}
|
||||
function closeLiveDataStatus() {
|
||||
liveDataStatusSheet.hidden = true;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'false');
|
||||
liveDataStatusTrigger.focus();
|
||||
}
|
||||
liveDataStatusTrigger.addEventListener('click', () => {
|
||||
renderLiveDataStatus();
|
||||
liveDataStatusSheet.hidden = false;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'true');
|
||||
qs('#close-live-data-status').focus();
|
||||
});
|
||||
qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus);
|
||||
liveDataStatusSheet.addEventListener('click', event => {
|
||||
if (event.target === liveDataStatusSheet) closeLiveDataStatus();
|
||||
});
|
||||
liveDataStatusSheet.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); }
|
||||
});
|
||||
liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
|
||||
const liveDataRefresh = liveDataStatus.createRefreshController({
|
||||
button: qs('#refresh-live-data'),
|
||||
output: qs('#live-data-status-result'),
|
||||
@@ -7924,6 +7919,7 @@
|
||||
const controller = createPushNotifications({
|
||||
control:qs('#push-updates'),
|
||||
status:qs('#push-update-status'),
|
||||
testControl:qs('#push-test'),
|
||||
deadlineControl:qs('#push-deadlines'),
|
||||
deadlineStatus:qs('#push-deadline-status'),
|
||||
deadlineHour:qs('#push-deadline-hour'),
|
||||
@@ -7976,15 +7972,25 @@
|
||||
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
||||
});
|
||||
installApp.start();
|
||||
const deviceStorage = createDeviceStorage.mount(document);
|
||||
await deviceStorage.start();
|
||||
deviceSetup = createMobileDeviceSetup.mount({
|
||||
document, installApp, promptStorage:localStorage,
|
||||
timerView,
|
||||
offlineAvailable:() => offlineStorageReady,
|
||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||
enableOffline:() => setOfflineWorkEnabled(true),
|
||||
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
|
||||
protectStorage:() => deviceStorage.requestPersistence(),
|
||||
notificationReadiness:() => pushController?.notificationReadiness()
|
||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
if (controller.notificationReadiness().state === 'blocked') {
|
||||
await controller.recoverPermission('updates');
|
||||
return;
|
||||
}
|
||||
qs('#push-updates').checked = true;
|
||||
await controller.change();
|
||||
},
|
||||
@@ -7995,10 +8001,13 @@
|
||||
if (!controller) return;
|
||||
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
||||
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
|
||||
if (controller.deadlineReadiness().state === 'blocked') {
|
||||
await controller.recoverPermission('deadline');
|
||||
return;
|
||||
}
|
||||
await controller.enableDeadline();
|
||||
},
|
||||
});
|
||||
await createDeviceStorage.mount(document).start();
|
||||
await deviceSetup.start();
|
||||
return deviceSetup;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
||||
@@ -37,10 +38,48 @@
|
||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||
}
|
||||
|
||||
async function inspectPersistence() {
|
||||
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
|
||||
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
|
||||
return persistence;
|
||||
}
|
||||
try {
|
||||
const protectedStorage = await options.storageManager.persisted();
|
||||
persistence = protectedStorage
|
||||
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
|
||||
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
|
||||
} catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
|
||||
}
|
||||
return persistence;
|
||||
}
|
||||
|
||||
function persistenceReadiness() {
|
||||
return {state:persistence.state, detail:persistence.detail};
|
||||
}
|
||||
|
||||
async function requestPersistence() {
|
||||
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
|
||||
let granted = false;
|
||||
try { granted = await options.storageManager.persist(); }
|
||||
catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
|
||||
return persistenceReadiness();
|
||||
}
|
||||
await refresh();
|
||||
if (!granted && persistence.state !== 'complete') {
|
||||
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
|
||||
}
|
||||
return persistenceReadiness();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let estimate = null;
|
||||
try { estimate = await options.storageManager?.estimate?.(); }
|
||||
catch (_error) { /* Storage estimates are optional. */ }
|
||||
await inspectPersistence();
|
||||
options.summary.textContent = estimate?.quota
|
||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||
: 'Browser storage usage is unavailable.';
|
||||
@@ -52,7 +91,7 @@
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
@@ -84,7 +123,7 @@
|
||||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
@@ -99,5 +138,5 @@
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return { refresh, start };
|
||||
return { refresh, start, persistenceReadiness, requestPersistence };
|
||||
});
|
||||
+49
-1
@@ -27,16 +27,29 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
|
||||
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
|
||||
<h2 id="sign-out-review-heading">Review sign out</h2>
|
||||
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
|
||||
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
|
||||
<div class="sign-out-review-actions">
|
||||
<button id="cancel-sign-out" type="button">Cancel</button>
|
||||
<button id="confirm-sign-out" type="button">Sign out</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<button id="return-from-live-data-status" type="button">Return to Today</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -112,6 +125,10 @@
|
||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Protect offline work</strong><p class="small" id="device-setup-protection-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-protection" type="button">Protect</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
@@ -186,6 +203,7 @@
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
@@ -309,6 +327,7 @@
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>
|
||||
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
@@ -541,7 +560,18 @@
|
||||
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
|
||||
<h3>Preview</h3>
|
||||
<pre id="today-summary-preview" class="today-summary-preview"></pre>
|
||||
<section class="today-summary-destination" aria-labelledby="today-summary-destination-title">
|
||||
<h3 id="today-summary-destination-title">Post to Gitea</h3>
|
||||
<p class="small muted">Enter the exact issue or pull request that should receive this reviewed comment.</p>
|
||||
<label for="today-summary-destination">Destination</label>
|
||||
<div class="today-summary-destination-controls">
|
||||
<input id="today-summary-destination" type="text" inputmode="text" autocomplete="off" maxlength="260" placeholder="owner/repo#42">
|
||||
<button id="validate-today-summary-destination" type="button">Review destination</button>
|
||||
</div>
|
||||
<div id="today-summary-target" class="today-summary-target small" aria-live="polite"></div>
|
||||
</section>
|
||||
<div class="today-summary-actions">
|
||||
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
|
||||
<button id="share-today-summary" type="button">Share summary</button>
|
||||
<button id="discard-today-summary" type="button">Discard draft</button>
|
||||
</div>
|
||||
@@ -1799,6 +1829,22 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">
|
||||
<section class="mobile-first-task-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Welcome to Work</p><h2 id="mobile-first-task-title">Start your first task</h2></div>
|
||||
<button id="close-mobile-first-task" type="button">Close</button>
|
||||
</header>
|
||||
<p class="small">Choose work, start it, then return to Work whenever you want to continue.</p>
|
||||
<p id="mobile-first-task-status" class="small" role="status" aria-live="polite"></p>
|
||||
<div class="mobile-first-task-actions">
|
||||
<button id="mobile-first-task-find" type="button">Find & start</button>
|
||||
<button id="mobile-first-task-create" type="button">Create & start</button>
|
||||
<button id="mobile-first-task-setup" type="button">Make this phone work-ready</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
@@ -1810,6 +1856,8 @@
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/sign-out-review.js"></script>
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/conversation-action-hydrator.js"></script>
|
||||
@@ -1901,6 +1949,7 @@
|
||||
<script src="static/mobile-today-command-bar.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-first-task.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
@@ -1917,7 +1966,6 @@
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
|
||||
@@ -74,5 +74,112 @@
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
function createSheetController(options) {
|
||||
let ownsDetour = false;
|
||||
let backgroundState = null;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundState) return;
|
||||
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundState) return;
|
||||
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundState = null;
|
||||
}
|
||||
|
||||
function finishClose() {
|
||||
if (options.sheet.hidden) return;
|
||||
options.sheet.hidden = true;
|
||||
options.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = true;
|
||||
releaseBackground();
|
||||
if (ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
options.trigger.focus?.();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (options.history?.state?.liveDataStatus) {
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose();
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!options.sheet.hidden) return;
|
||||
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
|
||||
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
options.trigger.setAttribute('aria-expanded', 'true');
|
||||
if (options.history && !options.history.state?.liveDataStatus) {
|
||||
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.trigger.addEventListener('click', open);
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.returnButton?.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (options.sheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, open, close };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const document = options.document;
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const trigger = qs('#open-live-data-status');
|
||||
trigger.addEventListener('click', options.onOpen);
|
||||
return createSheetController({
|
||||
sheet:qs('#live-data-status-sheet'), trigger,
|
||||
closeButton:qs('#close-live-data-status'),
|
||||
returnButton:qs('#return-from-live-data-status'),
|
||||
pausedStatus:qs('#live-data-status-today-paused'),
|
||||
timerView:options.timerView,
|
||||
history:options.window.history,
|
||||
historyTarget:options.window,
|
||||
escapeTarget:document,
|
||||
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return { describe, createRefreshController, createSheetController, mount };
|
||||
});
|
||||
@@ -5,13 +5,34 @@
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
['install', options.installButton, options.installStatus, options.install, 'Install'],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
let pendingResume = true;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundInert) return;
|
||||
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
}
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
@@ -34,9 +55,10 @@
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.textContent = step.actionLabel || defaultActionLabel;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
@@ -55,17 +77,31 @@
|
||||
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
|
||||
}
|
||||
await render();
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
if (options.history && !options.history.state?.deviceSetup) {
|
||||
options.history.pushState({...options.history.state, deviceSetup:true}, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close(resume = true) {
|
||||
function finishClose(resume = true) {
|
||||
options.sheet.hidden = true;
|
||||
releaseBackground();
|
||||
if (resume && ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
function close(resume = true) {
|
||||
if (options.history?.state?.deviceSetup) {
|
||||
pendingResume = resume;
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose(resume);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
@@ -81,7 +117,28 @@
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
if (event.key !== 'Tab' || options.sheet.hidden) return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.deviceSetup) {
|
||||
finishClose(pendingResume);
|
||||
pendingResume = true;
|
||||
}
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -104,12 +161,15 @@ if (typeof module === 'object' && module.exports) {
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
const window = options.window || options.document.defaultView;
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
@@ -119,6 +179,11 @@ function mountMobileDeviceSetup(options) {
|
||||
timerView:options.timerView,
|
||||
isMobile:options.isMobile || (() => innerWidth <= 600),
|
||||
escapeTarget:options.document,
|
||||
history:window?.history,
|
||||
historyTarget:window,
|
||||
backgroundElements:[
|
||||
qs('header'), qs('main'), qs('#mobile-task-dock'),
|
||||
].filter(Boolean),
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
@@ -126,15 +191,13 @@ function mountMobileDeviceSetup(options) {
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileFirstTask = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
|
||||
const doc = typeof document !== 'undefined' ? document : null;
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
options = Object.assign({
|
||||
storage: typeof localStorage !== 'undefined' ? localStorage : null,
|
||||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
createButton: doc?.querySelector('#mobile-first-task-create'),
|
||||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
|
||||
}, options);
|
||||
const prefix = 'stackchain.first-task.v1:';
|
||||
|
||||
function account() {
|
||||
return String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = account();
|
||||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function completed() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return false;
|
||||
try { return options.storage.getItem(currentKey) === 'complete'; }
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, 'complete'); }
|
||||
catch (_error) {}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const online = options.isOnline();
|
||||
const awaitingStart = options.hasWork();
|
||||
options.findButton.disabled = !online;
|
||||
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
|
||||
options.findButton.textContent = 'Find & start';
|
||||
options.createButton.textContent = 'Create & start';
|
||||
options.status.textContent = awaitingStart ?
|
||||
'Your task is ready. Start it from Find or create and start another task.' : online ?
|
||||
'Choose a task to claim and start, or create and start one of your own.' :
|
||||
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!required()) return false;
|
||||
render();
|
||||
if (!options.sheet.open) options.sheet.showModal();
|
||||
(options.findButton.disabled ? options.createButton : options.findButton).focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && !completed()) {
|
||||
markComplete();
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
return 'completed';
|
||||
}
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
callback();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
|
||||
options.createButton.addEventListener('click', () => handoff(options.onCreate));
|
||||
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
|
||||
options.closeButton.addEventListener('click', () => {
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
});
|
||||
options.eventTarget?.addEventListener('online', render);
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
return {required, open, refresh, render, start};
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
if (options.shouldActivate?.()) return 'activate';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'activate') options.openActivation();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
|
||||
@@ -2,11 +2,68 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
let pendingIntent = null;
|
||||
let recoveryPromise = null;
|
||||
|
||||
function blockedReadiness() {
|
||||
return {
|
||||
state:'blocked',
|
||||
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
|
||||
actionLabel:'Check again',
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRecoveryReadiness() {
|
||||
return notification.permission === 'granted'
|
||||
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
|
||||
: blockedReadiness();
|
||||
}
|
||||
|
||||
function notificationReadiness() {
|
||||
if (!configuration?.available || control?.disabled) {
|
||||
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
|
||||
}
|
||||
if (configuration.subscribed) {
|
||||
return {state:'complete', detail:'New update notifications are enabled.'};
|
||||
}
|
||||
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
|
||||
if (notification.permission === 'denied') return blockedReadiness();
|
||||
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
|
||||
}
|
||||
|
||||
function renderDeliveryHealth() {
|
||||
const health = Object.values(configuration?.delivery_health || {});
|
||||
const degraded = health.find(item => item?.state === 'degraded');
|
||||
if (testControl) testControl.hidden = !configuration?.subscribed;
|
||||
if (configuration?.subscribed && degraded) {
|
||||
const count = Number(degraded.consecutive_failures || 1);
|
||||
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
|
||||
return;
|
||||
}
|
||||
status.textContent = configuration?.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
async function testDelivery() {
|
||||
if (!testControl) return;
|
||||
testControl.disabled = true;
|
||||
status.textContent = 'Sending a test notification…';
|
||||
try {
|
||||
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
|
||||
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
|
||||
status.textContent = 'Test delivered. Update notifications are working on this device.';
|
||||
} catch (error) {
|
||||
status.textContent = 'Test delivery failed. Check your connection, then try again.';
|
||||
} finally {
|
||||
testControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
@@ -48,6 +105,7 @@
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
@@ -67,16 +125,22 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
const permission = notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
status.textContent = blockedReadiness().detail;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
@@ -95,11 +159,15 @@
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
if (testControl) testControl.hidden = false;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
pendingIntent = 'updates';
|
||||
const subscription = await ensureSubscription();
|
||||
if (subscription) pendingIntent = null;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -122,6 +190,7 @@
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked) pendingIntent = 'deadline';
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
@@ -140,6 +209,7 @@
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
pendingIntent = null;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
@@ -160,9 +230,28 @@
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
if (pendingIntent === 'deadline') {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
return await recoveryPromise;
|
||||
} finally {
|
||||
recoveryPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
@@ -176,14 +265,12 @@
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -19,6 +19,31 @@
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
let backgroundInert = null;
|
||||
|
||||
const backgroundElements = ['header', 'main', '#mobile-task-dock']
|
||||
.map(selector => root.document.querySelector?.(selector))
|
||||
.filter(Boolean);
|
||||
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
) || [])].filter(control => !control.hidden && !control.disabled);
|
||||
const containBackground = () => {
|
||||
if (!backgroundInert) {
|
||||
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
|
||||
}
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
};
|
||||
const releaseBackground = () => {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
};
|
||||
const finishClose = () => {
|
||||
devicesSheet.hidden = true;
|
||||
releaseBackground();
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
@@ -43,16 +68,41 @@
|
||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
root.addEventListener?.('popstate', event => {
|
||||
const section = event.state?.stackchainSecuritySection;
|
||||
const handleRouteChange = section => {
|
||||
if (section && !devicesSheet.hidden) {
|
||||
containBackground();
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
finishClose();
|
||||
}
|
||||
};
|
||||
root.addEventListener?.('popstate', event => {
|
||||
handleRouteChange(event.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.addEventListener?.('hashchange', () => {
|
||||
handleRouteChange(root.history?.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.document.addEventListener?.('keydown', event => {
|
||||
if (devicesSheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else finishClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,7 +131,8 @@
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
|
||||
else if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
@@ -224,6 +275,7 @@
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
containBackground();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v121';
|
||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
||||
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;
|
||||
@@ -15,6 +15,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/sign-out-review.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
@@ -112,6 +113,7 @@ const SHELL = [
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-today-command-bar.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
|
||||
+22
-14
@@ -38,9 +38,25 @@
|
||||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const allButton = root.document.getElementById('sign-out-all');
|
||||
let review;
|
||||
const features = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
|
||||
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
|
||||
},
|
||||
});
|
||||
const openReview = (mode, source) => features.run(
|
||||
'sign-out', { trigger: source }, () => {
|
||||
if (!review) review = root.createSignOutReview.mount(boundary);
|
||||
review.show(mode, source);
|
||||
},
|
||||
);
|
||||
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
|
||||
source?.addEventListener('click', () => openReview(mode, source))
|
||||
);
|
||||
boundary.requestSignOut = source => openReview('current', source || button);
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
@@ -51,14 +67,6 @@
|
||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||
}
|
||||
};
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
@@ -67,7 +75,7 @@
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
await features.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
@@ -607,8 +615,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
async function signOutAllDevices({ reviewed = false } = {}) {
|
||||
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createSignOutReview = factory;
|
||||
root.createSignOutReview.mount = boundary => {
|
||||
const document = root.document;
|
||||
const review = factory({
|
||||
sheet: document.getElementById('sign-out-review-sheet'),
|
||||
heading: document.getElementById('sign-out-review-heading'),
|
||||
summary: document.getElementById('sign-out-review-summary'),
|
||||
warning: document.getElementById('sign-out-review-warning'),
|
||||
cancelButton: document.getElementById('cancel-sign-out'),
|
||||
confirmButton: document.getElementById('confirm-sign-out'),
|
||||
escapeTarget: document,
|
||||
historyTarget: root,
|
||||
history: root.history,
|
||||
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
|
||||
getActiveElement: () => document.activeElement,
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
|
||||
onConfirm: mode => mode === 'all'
|
||||
? boundary.signOutAllDevices({ reviewed: true })
|
||||
: boundary.signOut(),
|
||||
});
|
||||
review.start();
|
||||
return review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
|
||||
let mode = 'current';
|
||||
let launcher = null;
|
||||
let open = false;
|
||||
let historyEntry = false;
|
||||
|
||||
function ownedItemCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function show(nextMode, source) {
|
||||
mode = nextMode;
|
||||
launcher = source;
|
||||
const itemCount = ownedItemCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
|
||||
let inventory = { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
|
||||
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
|
||||
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
|
||||
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
|
||||
options.summary.textContent = inventory.unavailable
|
||||
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
|
||||
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
|
||||
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
|
||||
if (mode === 'all') {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
|
||||
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
|
||||
} else {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
|
||||
: 'Signing out clears this device after the session is revoked.';
|
||||
}
|
||||
options.confirmButton.textContent = atRisk
|
||||
? 'Sign out and erase private work'
|
||||
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
|
||||
options.sheet.hidden = false;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
|
||||
open = true;
|
||||
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
|
||||
historyEntry = Boolean(options.history?.back);
|
||||
options.cancelButton.focus();
|
||||
}
|
||||
|
||||
function close({ restoreFocus = true } = {}) {
|
||||
if (!open) return;
|
||||
options.sheet.hidden = true;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
|
||||
open = false;
|
||||
if (restoreFocus) launcher?.focus?.();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
options.history.back();
|
||||
} else close();
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(mode);
|
||||
close({ restoreFocus: false });
|
||||
} catch (_error) {
|
||||
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
|
||||
} finally {
|
||||
options.confirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
|
||||
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
|
||||
options.cancelButton?.addEventListener('click', dismiss);
|
||||
options.confirmButton?.addEventListener('click', confirm);
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (!open) return;
|
||||
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = options.getActiveElement?.();
|
||||
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
|
||||
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, show, close };
|
||||
});
|
||||
+123
-4
@@ -1,6 +1,11 @@
|
||||
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
|
||||
function createTodaySummary({
|
||||
storage = null, getLogin = () => '', share = null, copy = null,
|
||||
resolveTarget = null, enqueueDurably = null,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
} = {}) {
|
||||
let draft = null;
|
||||
let draftKey = '';
|
||||
let postPromise = null;
|
||||
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
@@ -20,6 +25,7 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
...draft,
|
||||
worked:copyRows(draft.worked),
|
||||
tomorrow:copyRows(draft.tomorrow),
|
||||
target:draft.target ? { ...draft.target } : null,
|
||||
} : null;
|
||||
};
|
||||
const persist = () => {
|
||||
@@ -37,8 +43,15 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
|
||||
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
|
||||
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
|
||||
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
|
||||
typeof saved.target.state === 'string'));
|
||||
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
|
||||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
|
||||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
@@ -75,6 +88,9 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
|
||||
include_actuals:false,
|
||||
note:'',
|
||||
destination:'',
|
||||
target:null,
|
||||
operation_id:'',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
@@ -107,6 +123,57 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
setDestination(value) {
|
||||
syncAccount();
|
||||
if (!draft) return null;
|
||||
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
|
||||
draft.destination = String(value || '').trim().slice(0, 260);
|
||||
draft.target = null;
|
||||
draft.operation_id = '';
|
||||
persist();
|
||||
if (!match) return null;
|
||||
const repository = (match[1] + '/' + match[2]).toLowerCase();
|
||||
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
|
||||
},
|
||||
async validateDestination() {
|
||||
syncAccount();
|
||||
const parsed = this.setDestination(draft?.destination || '');
|
||||
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
|
||||
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
|
||||
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
|
||||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
|
||||
draft.target = {
|
||||
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
|
||||
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
|
||||
};
|
||||
draft.operation_id = String(createOperationId()).slice(0, 128);
|
||||
persist();
|
||||
return { ...draft.target };
|
||||
},
|
||||
postSummary() {
|
||||
if (postPromise) return postPromise;
|
||||
syncAccount();
|
||||
const body = this.text();
|
||||
if (!body) throw new Error('Select at least one summary item or add a note.');
|
||||
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
|
||||
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
|
||||
const target = { ...draft.target };
|
||||
const message = {
|
||||
kind:'search-reply', repository:target.repository, number:target.number,
|
||||
targetKind:target.kind, body, operationId:draft.operation_id,
|
||||
};
|
||||
postPromise = (async () => {
|
||||
try {
|
||||
const admission = await enqueueDurably(message);
|
||||
discard();
|
||||
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
|
||||
} finally {
|
||||
postPromise = null;
|
||||
}
|
||||
})();
|
||||
return postPromise;
|
||||
},
|
||||
discard,
|
||||
text() {
|
||||
syncAccount();
|
||||
@@ -162,6 +229,10 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
qs('#today-summary-include-actuals').checked = draft.include_actuals;
|
||||
qs('#today-summary-note').value = draft.note;
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
qs('#today-summary-destination').value = draft.destination || '';
|
||||
qs('#today-summary-target').textContent = draft.target ?
|
||||
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
|
||||
qs('#post-today-summary').disabled = !draft.target;
|
||||
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
|
||||
@@ -202,6 +273,34 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDestination(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Checking destination…';
|
||||
try {
|
||||
await summary.validateDestination();
|
||||
render();
|
||||
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postDraft(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
|
||||
try {
|
||||
const result = await summary.postSummary();
|
||||
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
|
||||
result.target.repository + '#' + result.target.number + '.';
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-summary').addEventListener('click', close);
|
||||
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
|
||||
@@ -212,21 +311,41 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
summary.setNote(event.currentTarget.value);
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
});
|
||||
qs('#today-summary-destination').addEventListener('input', event => {
|
||||
summary.setDestination(event.currentTarget.value);
|
||||
qs('#today-summary-target').textContent = '';
|
||||
qs('#post-today-summary').disabled = true;
|
||||
});
|
||||
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
|
||||
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
|
||||
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
||||
}
|
||||
|
||||
return {
|
||||
open, close, render, bind, shareDraft,
|
||||
open, close, render, bind, shareDraft, validateDestination, postDraft,
|
||||
resume() { if (summary.restore()) show(); },
|
||||
};
|
||||
}
|
||||
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
|
||||
const targetResolver = resolveTarget || (async target => {
|
||||
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
|
||||
'/preview?kind=issue');
|
||||
if (String(item.repository || '').toLowerCase() !== target.repository ||
|
||||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
|
||||
throw new Error('That exact issue or pull request is not visible to this account.');
|
||||
}
|
||||
return { repository:target.repository, number:target.number, kind:item.kind,
|
||||
title:item.title || '', state:item.state || '' };
|
||||
});
|
||||
const summary = createTodaySummary({
|
||||
storage:localStorage,
|
||||
getLogin,
|
||||
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
|
||||
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
|
||||
resolveTarget:targetResolver,
|
||||
enqueueDurably,
|
||||
});
|
||||
const view = createTodaySummaryView({ summary, qs, escapeHtml });
|
||||
view.bind();
|
||||
|
||||
@@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
const validDetour = state => {
|
||||
const pending = state.detour_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ?
|
||||
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
|
||||
};
|
||||
const validBreak = state => {
|
||||
@@ -114,7 +114,8 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
if (state.active_identity === identity &&
|
||||
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
@@ -289,7 +290,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginDetour(reason) {
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null;
|
||||
const state = read();
|
||||
const existing = validDetour(state);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -1,41 +1,75 @@
|
||||
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
|
||||
async function loadWorkspace({
|
||||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const loader = createLoader({
|
||||
document,
|
||||
urls: { 'today-timer': url },
|
||||
});
|
||||
if (status) status.textContent = 'Starting workspace…';
|
||||
try {
|
||||
await loader.load('today-timer');
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
const urls = {'today-timer':url};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
|
||||
return loader.load('today-timer');
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
}
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (window?.location?.reload) {
|
||||
if (cameOnline) window.location.reload();
|
||||
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = window
|
||||
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
|
||||
: 'Workspace could not load. Check your connection, then reload to retry.';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
+10
-3
@@ -27,14 +27,15 @@ FEATURE_SOURCES = {
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
@@ -114,7 +115,13 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
workspace_preload = (
|
||||
f'<link rel="preload" as="script" '
|
||||
f'href="{feature_bundles["today-timer"].runtime_name}">'
|
||||
)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
|
||||
)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>",
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
|
||||
+51
-4
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -29,12 +30,17 @@ class IdempotencyLedger:
|
||||
max_entries: int,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
clock: Callable[[], float] = time.time,
|
||||
encryption_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="idempotency-ledger",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -67,6 +73,17 @@ class IdempotencyLedger:
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def _seal(self, value: str, *, key: str, field: str) -> str:
|
||||
return self._cipher.seal(value, binding=f"{key}:{field}")
|
||||
|
||||
def _open(self, payload: str, *, key: str, field: str) -> tuple[str, bool]:
|
||||
value, legacy = self._cipher.open(payload, binding=f"{key}:{field}")
|
||||
if legacy:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True), True
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("idempotency ledger payload is invalid")
|
||||
return value, legacy
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
@@ -84,10 +101,40 @@ class IdempotencyLedger:
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
stored_fingerprint, legacy_fingerprint = self._open(
|
||||
row[0], key=key, field="fingerprint"
|
||||
)
|
||||
if legacy_fingerprint:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET fingerprint = ? "
|
||||
"WHERE key = ? AND fingerprint = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_fingerprint, key=key, field="fingerprint"
|
||||
),
|
||||
key,
|
||||
row[0],
|
||||
),
|
||||
)
|
||||
if stored_fingerprint != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
stored_response, legacy_response = self._open(
|
||||
row[2], key=key, field="response"
|
||||
)
|
||||
if legacy_response:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET response_json = ? "
|
||||
"WHERE key = ? AND response_json = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_response, key=key, field="response"
|
||||
),
|
||||
key,
|
||||
row[2],
|
||||
),
|
||||
)
|
||||
return Reservation("completed", json.loads(stored_response))
|
||||
if row[3] <= now - self.ttl_seconds:
|
||||
return Reservation("uncertain")
|
||||
return Reservation("pending")
|
||||
@@ -110,7 +157,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
(key, self._seal(encoded, key=key, field="fingerprint"), now),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
@@ -129,7 +176,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
(self._seal(encoded, key=key, field="response"), self.clock(), key),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
|
||||
+96
-23
@@ -1,12 +1,12 @@
|
||||
"""Durable, account-scoped Later deferrals."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
|
||||
|
||||
class LaterStore:
|
||||
@@ -17,6 +17,7 @@ class LaterStore:
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
encryption_key: bytes | None = None,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -24,6 +25,10 @@ class LaterStore:
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="later",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
@@ -99,11 +104,55 @@ class LaterStore:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "records": {}}
|
||||
return {"revision": int(row[0]), "records": json.loads(row[1])}
|
||||
return {"revision": 0, "records": {}}, False
|
||||
records, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
|
||||
if not isinstance(records, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "records": records}, legacy
|
||||
|
||||
def _sealed_records(self, login: str, records: dict) -> str:
|
||||
return self._cipher.seal(records, binding=f"plan:{login}")
|
||||
|
||||
def _item_revisions(
|
||||
self, connection: sqlite3.Connection, login: str
|
||||
) -> tuple[dict[str, int], dict[str, str]]:
|
||||
revisions: dict[str, int] = {}
|
||||
stored_ids: dict[str, str] = {}
|
||||
for stored_item_id, revision in connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
):
|
||||
if stored_item_id.startswith("v1:"):
|
||||
item_id, _legacy = self._cipher.open(
|
||||
stored_item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
else:
|
||||
item_id = stored_item_id
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
revisions[item_id] = int(revision)
|
||||
stored_ids[item_id] = stored_item_id
|
||||
return revisions, stored_ids
|
||||
|
||||
def _migrate_item_ids(
|
||||
self, connection: sqlite3.Connection, login: str, stored_ids: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
migrated = dict(stored_ids)
|
||||
for item_id, stored_item_id in stored_ids.items():
|
||||
if stored_item_id.startswith("v1:"):
|
||||
continue
|
||||
sealed_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE later_item_revisions SET item_id = ? "
|
||||
"WHERE login = ? AND item_id = ?",
|
||||
(sealed_item_id, login, stored_item_id),
|
||||
)
|
||||
migrated[item_id] = sealed_item_id
|
||||
return migrated
|
||||
|
||||
@staticmethod
|
||||
def _validate_wake_at(wake_at: str | None) -> str:
|
||||
@@ -116,12 +165,21 @@ class LaterStore:
|
||||
return wake_at
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy_plan = self._snapshot(row, login)
|
||||
_revisions, stored_item_ids = self._item_revisions(connection, login)
|
||||
if row is not None and legacy_plan:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET records = ? WHERE login = ? AND records = ?",
|
||||
(self._sealed_records(login, snapshot["records"]), login, row[1]),
|
||||
)
|
||||
self._migrate_item_ids(connection, login, stored_item_ids)
|
||||
return snapshot
|
||||
|
||||
def apply(
|
||||
self,
|
||||
@@ -152,7 +210,7 @@ class LaterStore:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
snapshot, legacy_plan = self._snapshot(row, login)
|
||||
records = dict(snapshot["records"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
@@ -161,15 +219,19 @@ class LaterStore:
|
||||
|
||||
# Existing databases predate per-item revisions. Conservatively mark
|
||||
# active deferrals as changed at the latest known plan revision.
|
||||
item_revisions, stored_item_ids = self._item_revisions(connection, login)
|
||||
stored_item_ids = self._migrate_item_ids(connection, login, stored_item_ids)
|
||||
for item_id in records:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
item_revisions = dict(connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall())
|
||||
if item_id not in item_revisions:
|
||||
stored_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, stored_item_id, revision),
|
||||
)
|
||||
item_revisions[item_id] = revision
|
||||
stored_item_ids[item_id] = stored_item_id
|
||||
batch_start_item_revisions = dict(item_revisions)
|
||||
|
||||
for operation in operations:
|
||||
@@ -218,21 +280,32 @@ class LaterStore:
|
||||
revision += 1 if changed else 0
|
||||
if changed:
|
||||
item_revisions[item_id] = revision
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
stored_item_id = stored_item_ids.get(item_id)
|
||||
if stored_item_id is None:
|
||||
stored_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
stored_item_ids[item_id] = stored_item_id
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, stored_item_id, revision),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE later_item_revisions SET revision = ? "
|
||||
"WHERE login = ? AND item_id = ?",
|
||||
(revision, login, stored_item_id),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
serialized = self._sealed_records(login, records)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
elif accepted or legacy_plan:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
|
||||
+69
-16
@@ -3,6 +3,7 @@ import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -55,6 +56,7 @@ from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
)
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import build_push_subscription_store
|
||||
@@ -71,6 +73,7 @@ from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
|
||||
@@ -2158,6 +2161,9 @@ async def push_status(request: Request):
|
||||
preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
||||
)
|
||||
delivery_health = await asyncio.to_thread(
|
||||
_push_subscription_store.delivery_health, device_id
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
"subscribed": subscribed,
|
||||
@@ -2167,6 +2173,7 @@ async def push_status(request: Request):
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
"snoozed_until": preferences["snoozed_until"],
|
||||
"delivery_health": delivery_health,
|
||||
}
|
||||
|
||||
|
||||
@@ -2220,6 +2227,52 @@ async def unsubscribe_push(request: Request):
|
||||
return {"subscribed": False}
|
||||
|
||||
|
||||
@app.post("/api/v1/push-subscription/test")
|
||||
async def test_push_notification(request: Request):
|
||||
configuration = _push_configuration()
|
||||
if not configuration.enabled:
|
||||
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
subscription = await asyncio.to_thread(
|
||||
_push_subscription_store.subscription_for_session, device_id
|
||||
)
|
||||
if subscription is None:
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
payload = json.dumps({
|
||||
"title": "Stackchain notifications are working",
|
||||
"body": "This device can receive private work alerts.",
|
||||
"route": "#/device-setup",
|
||||
"tag": "stackchain-push-test",
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
await send_web_push(subscription, payload, configuration)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Re-enable notifications for this device"
|
||||
) from error
|
||||
reason = "timeout" if isinstance(error, (asyncio.TimeoutError, TimeoutError)) else "provider"
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivery_failed,
|
||||
device_id,
|
||||
"unread",
|
||||
reason,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Test notification delivery failed",
|
||||
headers={"Retry-After": "1"},
|
||||
) from error
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivery_succeeded, device_id, "unread"
|
||||
)
|
||||
return {"delivery_state": "healthy", "delivered": True}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/deadlines")
|
||||
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
@@ -2367,7 +2420,7 @@ async def get_completed_filed_reviews(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Completed Filed review synchronization is unavailable",
|
||||
@@ -2388,7 +2441,7 @@ async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Completed Filed review synchronization is unavailable",
|
||||
@@ -2401,7 +2454,7 @@ async def get_saved_searches(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Saved Search synchronization is unavailable",
|
||||
@@ -2431,7 +2484,7 @@ async def replace_saved_searches(payload: SavedSearchCollection):
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Saved Search synchronization is unavailable",
|
||||
@@ -2487,7 +2540,7 @@ async def get_today_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today synchronization is unavailable",
|
||||
@@ -2521,7 +2574,7 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
)
|
||||
except TodayPlanFull:
|
||||
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today synchronization is unavailable",
|
||||
@@ -2534,7 +2587,7 @@ async def get_today_session():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get_session, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Today session synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
@@ -2553,7 +2606,7 @@ async def update_today_session(payload: TodaySessionUpdate):
|
||||
status_code=409,
|
||||
detail={"code": "session_changed", "session": error.session},
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Today session synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
@@ -2565,7 +2618,7 @@ async def get_today_recaps(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today recap history is unavailable",
|
||||
@@ -2587,7 +2640,7 @@ async def save_today_recap(payload: TodayRecap, response: Response):
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today recap could not be saved",
|
||||
@@ -2639,7 +2692,7 @@ async def save_today_recap_and_log_time(
|
||||
raise ValueError("time log target must match the saved recap")
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
|
||||
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
@@ -2708,7 +2761,7 @@ async def get_later_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_later_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Later synchronization is unavailable",
|
||||
@@ -2738,7 +2791,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Later synchronization is unavailable",
|
||||
@@ -3886,7 +3939,7 @@ async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> Non
|
||||
_live_snapshot_value = updated
|
||||
try:
|
||||
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
# The upstream mutation already succeeded; keep process-local filtering.
|
||||
pass
|
||||
|
||||
@@ -4066,7 +4119,7 @@ async def live_snapshot(
|
||||
events_revision=events_revision,
|
||||
notifications_revision=notifications_revision,
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
return JSONResponse(
|
||||
{"error": "Gitea live snapshot state is temporarily unavailable"},
|
||||
status_code=503,
|
||||
@@ -4361,7 +4414,7 @@ async def defer_notification(
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
return JSONResponse(
|
||||
{"error": "Later synchronization is unavailable. Please retry."},
|
||||
status_code=503,
|
||||
|
||||
@@ -57,6 +57,12 @@ class _PinnedHTTPSAdapter(requests.adapters.HTTPAdapter):
|
||||
return host, tls
|
||||
|
||||
|
||||
def _delivery_failure_reason(error: Exception) -> str:
|
||||
if isinstance(error, (asyncio.TimeoutError, TimeoutError)):
|
||||
return "timeout"
|
||||
return "provider"
|
||||
|
||||
|
||||
async def send_web_push(
|
||||
subscription: dict,
|
||||
payload: str,
|
||||
@@ -214,10 +220,20 @@ async def dispatch_unread_updates(
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
can_send_digest = False
|
||||
# Leave this device's transient failures unseen for a later
|
||||
# poll instead of paying the endpoint deadline repeatedly.
|
||||
break
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, delivery.session_id, "unread"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
@@ -262,12 +278,21 @@ async def dispatch_unread_updates(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_digest_pending,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
return count
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, delivery.session_id, "unread"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
@@ -279,10 +304,19 @@ async def dispatch_unread_updates(
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
@@ -461,9 +495,20 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
if isinstance(error, UnsafePushEndpoint):
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"deadline",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "deadline"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
|
||||
@@ -49,6 +49,9 @@ class DisabledPushSubscriptionStore:
|
||||
def is_subscribed(self, session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def subscription_for_session(self, session_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
def deadline_preferences(
|
||||
self, session_id: str, *, now: float | None = None
|
||||
) -> dict:
|
||||
@@ -102,6 +105,15 @@ class DisabledPushSubscriptionStore:
|
||||
def mark_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delivery_health(self, *args, **kwargs) -> dict:
|
||||
return {}
|
||||
|
||||
def mark_delivery_failed(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_delivery_succeeded(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_push_subscription_store(
|
||||
path: str | Path, *, push_enabled: bool
|
||||
@@ -183,6 +195,17 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_delivery_health (
|
||||
session_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempted_at REAL NOT NULL,
|
||||
last_succeeded_at REAL,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (session_id, channel),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -316,6 +339,67 @@ class PushSubscriptionStore:
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def subscription_for_session(self, session_id: str) -> dict | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return self._open_subscription(session_id, row[0]) if row else None
|
||||
|
||||
def delivery_health(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT channel, consecutive_failures, last_attempted_at,
|
||||
last_succeeded_at, reason
|
||||
FROM push_delivery_health WHERE session_id = ? ORDER BY channel""",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
row[0]: {
|
||||
"state": "degraded" if row[1] else "healthy",
|
||||
"consecutive_failures": row[1],
|
||||
"last_attempted_at": row[2],
|
||||
"last_succeeded_at": row[3],
|
||||
"reason": row[4],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def mark_delivery_failed(
|
||||
self, session_id: str, channel: str, reason: str, *, now: float | None = None
|
||||
) -> None:
|
||||
attempted_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_delivery_health(
|
||||
session_id, channel, consecutive_failures, last_attempted_at, reason
|
||||
) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(session_id, channel) DO UPDATE SET
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
last_attempted_at = excluded.last_attempted_at,
|
||||
reason = excluded.reason""",
|
||||
(session_id, channel, attempted_at, reason),
|
||||
)
|
||||
|
||||
def mark_delivery_succeeded(
|
||||
self, session_id: str, channel: str, *, now: float | None = None
|
||||
) -> None:
|
||||
attempted_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_delivery_health(
|
||||
session_id, channel, consecutive_failures, last_attempted_at,
|
||||
last_succeeded_at, reason
|
||||
) VALUES (?, ?, 0, ?, ?, NULL)
|
||||
ON CONFLICT(session_id, channel) DO UPDATE SET
|
||||
consecutive_failures = 0,
|
||||
last_attempted_at = excluded.last_attempted_at,
|
||||
last_succeeded_at = excluded.last_succeeded_at,
|
||||
reason = NULL""",
|
||||
(session_id, channel, attempted_at, attempted_at),
|
||||
)
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
|
||||
+34
-11
@@ -1,11 +1,11 @@
|
||||
"""Durable, account-scoped saved Search views."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
@@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError):
|
||||
|
||||
|
||||
class SavedSearchStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="saved-searches",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
@@ -50,19 +61,31 @@ class SavedSearchStore:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "views": []} if row is None else {
|
||||
"revision": int(row[0]), "views": json.loads(row[1])
|
||||
}
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "views": []}, False
|
||||
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
|
||||
if not isinstance(views, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "views": views}, legacy
|
||||
|
||||
def _sealed_views(self, login: str, views: list[dict]) -> str:
|
||||
return self._cipher.seal(views, binding=f"views:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||
(self._login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
|
||||
(self._sealed_views(login, snapshot["views"]), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||
if not isinstance(views, list):
|
||||
@@ -113,13 +136,13 @@ class SavedSearchStore:
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(views)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
serialized = self._sealed_views(login, normalized)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if current["revision"] != expected_revision:
|
||||
raise SavedSearchConflict(current)
|
||||
revision = expected_revision + 1
|
||||
|
||||
+137
-23
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
)
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
@@ -39,12 +44,22 @@ class SecurityEventStore:
|
||||
max_events: int = 10_000,
|
||||
retention_seconds: int = 90 * 24 * 60 * 60,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
encryption_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_events = max(1, max_events)
|
||||
self.retention_seconds = max(1, retention_seconds)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
try:
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="security-events",
|
||||
)
|
||||
except PrivateStateEncryptionError as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _bounded(value: str | None, limit: int) -> str | None:
|
||||
@@ -60,10 +75,7 @@ class SecurityEventStore:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT,
|
||||
device_label TEXT,
|
||||
target TEXT,
|
||||
payload TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
@@ -81,6 +93,11 @@ class SecurityEventStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
plaintext_columns = {"kind", "method", "device_label", "target"}
|
||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||
self._migrate_plaintext(
|
||||
connection, has_payload="payload" in columns
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
@@ -95,6 +112,56 @@ class SecurityEventStore:
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _migrate_plaintext(
|
||||
self, connection: sqlite3.Connection, *, has_payload: bool
|
||||
) -> None:
|
||||
connection.execute("PRAGMA secure_delete = ON")
|
||||
payload_column = "payload," if has_payload else "NULL AS payload,"
|
||||
rows = connection.execute(
|
||||
f"SELECT id, {payload_column} kind, method, device_label, target, "
|
||||
"created_at, status, operation_id FROM security_events ORDER BY id"
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE security_events_encrypted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
for (
|
||||
event_id,
|
||||
payload,
|
||||
kind,
|
||||
method,
|
||||
device_label,
|
||||
target,
|
||||
created_at,
|
||||
status,
|
||||
operation_id,
|
||||
) in rows:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events_encrypted "
|
||||
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
event_id,
|
||||
payload
|
||||
or self._seal_event(
|
||||
event_id, kind, method, device_label, target
|
||||
),
|
||||
created_at,
|
||||
status,
|
||||
operation_id,
|
||||
),
|
||||
)
|
||||
connection.execute("DROP TABLE security_events")
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events_encrypted RENAME TO security_events"
|
||||
)
|
||||
|
||||
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE created_at < ?",
|
||||
@@ -106,6 +173,24 @@ class SecurityEventStore:
|
||||
(self.max_events,),
|
||||
)
|
||||
|
||||
def _seal_event(
|
||||
self,
|
||||
event_id: int,
|
||||
kind: str,
|
||||
method: str | None,
|
||||
device_label: str | None,
|
||||
target: str | None,
|
||||
) -> str:
|
||||
return self._cipher.seal(
|
||||
{
|
||||
"kind": self._bounded(kind, 48) or "security_event",
|
||||
"method": self._bounded(method, 32),
|
||||
"device_label": self._bounded(device_label, 64),
|
||||
"target": self._bounded(target, 255),
|
||||
},
|
||||
binding=f"event:{event_id}",
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -117,16 +202,17 @@ class SecurityEventStore:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
||||
(now,),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
payload = self._seal_event(
|
||||
event_id, kind, method, device_label, target
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
),
|
||||
"UPDATE security_events SET payload = ? WHERE id = ?",
|
||||
(payload, event_id),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -146,16 +232,19 @@ class SecurityEventStore:
|
||||
operation_id = secrets.token_urlsafe(24)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
||||
"VALUES (?, 'pending', ?)",
|
||||
(now, operation_id),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||
"UPDATE security_events SET payload = ? WHERE id = ?",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
operation_id,
|
||||
self._seal_event(
|
||||
event_id, kind, method, device_label, target
|
||||
),
|
||||
event_id,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
@@ -204,7 +293,7 @@ class SecurityEventStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, kind, method, device_label, target, created_at, status "
|
||||
"SELECT id, payload, created_at, status "
|
||||
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
@@ -214,7 +303,32 @@ class SecurityEventStore:
|
||||
) from exc
|
||||
has_more = len(rows) > bounded_limit
|
||||
visible = rows[:bounded_limit]
|
||||
events = []
|
||||
try:
|
||||
for event_id, payload, created_at, status in visible:
|
||||
value, _legacy = self._cipher.open(
|
||||
payload, binding=f"event:{event_id}"
|
||||
)
|
||||
if not isinstance(value, dict) or not isinstance(value.get("kind"), str):
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state could not be decrypted"
|
||||
)
|
||||
events.append(
|
||||
SecurityEvent(
|
||||
event_id,
|
||||
value["kind"],
|
||||
value.get("method"),
|
||||
value.get("device_label"),
|
||||
value.get("target"),
|
||||
created_at,
|
||||
status,
|
||||
)
|
||||
)
|
||||
except PrivateStateEncryptionError as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
return SecurityEventPage(
|
||||
events=[SecurityEvent(*row) for row in visible],
|
||||
events=events,
|
||||
next_cursor=visible[-1][0] if has_more else None,
|
||||
)
|
||||
+257
-75
@@ -7,6 +7,7 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
@@ -31,6 +32,7 @@ class TodayStore:
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
recap_limit: int = 100,
|
||||
encryption_key: bytes | None = None,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -40,6 +42,10 @@ class TodayStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="today",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
@@ -162,31 +168,63 @@ class TodayStore:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}, False
|
||||
if row[1].startswith("v1:"):
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
ids = payload.get("ids")
|
||||
estimates = payload.get("estimates")
|
||||
capacity_minutes = payload.get("capacity_minutes")
|
||||
plan_date = payload.get("plan_date")
|
||||
timezone = payload.get("timezone")
|
||||
else:
|
||||
legacy = True
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
capacity_minutes = row[2]
|
||||
plan_date = row[4] if len(row) > 4 else None
|
||||
timezone = row[5] if len(row) > 5 else None
|
||||
if not isinstance(ids, list) or not isinstance(estimates, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
snapshot = {
|
||||
"revision": int(row[0]),
|
||||
"ids": ids,
|
||||
"capacity_minutes": row[2],
|
||||
"capacity_minutes": capacity_minutes,
|
||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
||||
}
|
||||
if len(row) > 4 and row[4]:
|
||||
snapshot["plan_date"] = row[4]
|
||||
snapshot["timezone"] = row[5]
|
||||
return snapshot
|
||||
if plan_date:
|
||||
snapshot["plan_date"] = plan_date
|
||||
snapshot["timezone"] = timezone
|
||||
return snapshot, legacy
|
||||
|
||||
def _sealed_plan(self, login: str, snapshot: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"ids": snapshot["ids"],
|
||||
"capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": snapshot["estimates"],
|
||||
"plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
}, binding=f"plan:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET ids = ?, capacity_minutes = NULL, estimates = '{}', "
|
||||
"plan_date = NULL, timezone = NULL WHERE login = ? AND ids = ?",
|
||||
(self._sealed_plan(login, snapshot), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> dict:
|
||||
@@ -195,20 +233,42 @@ class TodayStore:
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
def get_session(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||
"FROM today_sessions WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session()
|
||||
return self._empty_session(), False
|
||||
if row[1].startswith("v1:"):
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
|
||||
return {
|
||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||
"break_deadline_at": row[5], "updated_at": row[6],
|
||||
}
|
||||
}, True
|
||||
|
||||
def _sealed_session(self, login: str, session: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"device_id": session["device_id"], "identity": session["identity"],
|
||||
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
|
||||
"break_deadline_at": session["break_deadline_at"],
|
||||
}, binding=f"session:{login}")
|
||||
|
||||
def get_session(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||
"FROM today_sessions WHERE login = ?", (login,),
|
||||
).fetchone()
|
||||
session, legacy = self._session_snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE today_sessions SET device_id = ?, identity = '', elapsed_ms = 0, "
|
||||
"running = 0, break_deadline_at = NULL WHERE login = ? AND device_id = ?",
|
||||
(self._sealed_session(login, session), login, row[1]),
|
||||
)
|
||||
return session
|
||||
|
||||
def update_session(
|
||||
self, login: str, *, base_revision: int, device_id: str,
|
||||
@@ -222,24 +282,25 @@ class TodayStore:
|
||||
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||
"FROM today_sessions WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current_revision = int(current[0]) if current else 0
|
||||
current_session, _legacy = self._session_snapshot(current, login)
|
||||
current_revision = current_session["revision"]
|
||||
if base_revision != current_revision:
|
||||
session = self._empty_session() if current is None else {
|
||||
"revision": current_revision, "device_id": current[1], "identity": current[2],
|
||||
"elapsed_ms": int(current[3]), "running": bool(current[4]),
|
||||
"break_deadline_at": current[5], "updated_at": current[6],
|
||||
}
|
||||
raise TodaySessionConflict(session)
|
||||
raise TodaySessionConflict(current_session)
|
||||
revision = current_revision + 1
|
||||
session = {
|
||||
"revision": revision, "device_id": device_id, "identity": identity,
|
||||
"elapsed_ms": elapsed_ms, "running": bool(running),
|
||||
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
|
||||
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
|
||||
"break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
|
||||
(login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
|
||||
(login, revision, self._sealed_session(login, session), "", 0, 0, None, updated_at),
|
||||
)
|
||||
return self.get_session(login)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||
@@ -273,9 +334,24 @@ class TodayStore:
|
||||
})
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
||||
items = json.loads(serialized)
|
||||
def _recap_snapshot(
|
||||
self, login: str, encrypted_session_id: str, created_at: float, serialized: str
|
||||
) -> tuple[dict, bool]:
|
||||
if encrypted_session_id.startswith("v1:"):
|
||||
session_id, legacy_session = self._cipher.open(
|
||||
encrypted_session_id, binding=f"recap-id:{login}"
|
||||
)
|
||||
items, legacy_items = self._cipher.open(
|
||||
serialized, binding=f"recap-items:{login}:{session_id}"
|
||||
)
|
||||
else:
|
||||
session_id, legacy_session = encrypted_session_id, True
|
||||
try:
|
||||
items, legacy_items = json.loads(serialized), True
|
||||
except json.JSONDecodeError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
if not isinstance(session_id, str) or not isinstance(items, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
||||
actual = sum(item["actual_minutes"] for item in items)
|
||||
return {
|
||||
@@ -285,7 +361,20 @@ class TodayStore:
|
||||
"estimated_minutes": estimated,
|
||||
"actual_minutes": actual,
|
||||
"variance_minutes": actual - estimated,
|
||||
}
|
||||
}, legacy_session or legacy_items
|
||||
|
||||
def _migrate_recap(self, connection, login: str, stored_session: str, snapshot: dict) -> None:
|
||||
encrypted_session = self._cipher.seal(
|
||||
snapshot["session_id"], binding=f"recap-id:{login}"
|
||||
)
|
||||
encrypted_items = self._cipher.seal(
|
||||
snapshot["items"], binding=f"recap-items:{login}:{snapshot['session_id']}"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE today_recaps SET session_id = ?, items = ? "
|
||||
"WHERE login = ? AND session_id = ?",
|
||||
(encrypted_session, encrypted_items, login, stored_session),
|
||||
)
|
||||
|
||||
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
@@ -293,19 +382,25 @@ class TodayStore:
|
||||
raise ValueError("session_id is required and bounded")
|
||||
session_id = session_id.strip()
|
||||
normalized = self._normalize_recap_items(items)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
||||
(login, session_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return self._recap_snapshot(session_id, existing[0], existing[1])
|
||||
existing_rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ?", (login,)
|
||||
).fetchall()
|
||||
for existing in existing_rows:
|
||||
snapshot, legacy = self._recap_snapshot(login, *existing)
|
||||
if snapshot["session_id"] == session_id:
|
||||
if legacy:
|
||||
self._migrate_recap(connection, login, existing[0], snapshot)
|
||||
return snapshot
|
||||
created_at = self.clock()
|
||||
encrypted_session_id = self._cipher.seal(session_id, binding=f"recap-id:{login}")
|
||||
serialized = self._cipher.seal(
|
||||
normalized, binding=f"recap-items:{login}:{session_id}"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
||||
(login, session_id, created_at, serialized),
|
||||
(login, encrypted_session_id, created_at, serialized),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
||||
@@ -313,53 +408,135 @@ class TodayStore:
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.recap_limit),
|
||||
)
|
||||
return self._recap_snapshot(session_id, created_at, serialized)
|
||||
return self._recap_snapshot(login, encrypted_session_id, created_at, serialized)[0]
|
||||
|
||||
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
||||
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(self._normalize_login(login), bounded_limit),
|
||||
(login, bounded_limit),
|
||||
).fetchall()
|
||||
return [self._recap_snapshot(*row) for row in rows]
|
||||
snapshots = []
|
||||
for row in rows:
|
||||
snapshot, legacy = self._recap_snapshot(login, *row)
|
||||
if legacy:
|
||||
self._migrate_recap(connection, login, row[0], snapshot)
|
||||
snapshots.append(snapshot)
|
||||
return snapshots
|
||||
|
||||
def _time_log_snapshot(self, login: str, row) -> tuple[dict, bool]:
|
||||
encrypted_session, encrypted_identity, payload_value, status_value = row
|
||||
if encrypted_session.startswith("v1:"):
|
||||
session_id = self._cipher.open(
|
||||
encrypted_session, binding=f"time-log-session:{login}"
|
||||
)[0]
|
||||
identity = self._cipher.open(
|
||||
encrypted_identity, binding=f"time-log-identity:{login}:{session_id}"
|
||||
)[0]
|
||||
payload, legacy = self._cipher.open(
|
||||
str(payload_value), binding=f"time-log-payload:{login}:{session_id}:{identity}"
|
||||
)
|
||||
if not isinstance(session_id, str) or not isinstance(identity, str) or not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {
|
||||
"session_id": session_id, "identity": identity,
|
||||
"actual_minutes": payload.get("actual_minutes"), "status": payload.get("status"),
|
||||
"stored_session": encrypted_session, "stored_identity": encrypted_identity,
|
||||
}, legacy
|
||||
return {
|
||||
"session_id": encrypted_session, "identity": encrypted_identity,
|
||||
"actual_minutes": payload_value, "status": status_value,
|
||||
"stored_session": encrypted_session, "stored_identity": encrypted_identity,
|
||||
}, True
|
||||
|
||||
def _sealed_time_log(self, login: str, session_id: str, identity: str, actual: int, status: str) -> tuple[str, str, str]:
|
||||
return (
|
||||
self._cipher.seal(session_id, binding=f"time-log-session:{login}"),
|
||||
self._cipher.seal(identity, binding=f"time-log-identity:{login}:{session_id}"),
|
||||
self._cipher.seal(
|
||||
{"actual_minutes": actual, "status": status},
|
||||
binding=f"time-log-payload:{login}:{session_id}:{identity}",
|
||||
),
|
||||
)
|
||||
|
||||
def _migrate_time_log(self, connection, login: str, snapshot: dict) -> dict:
|
||||
sealed_session, sealed_identity, sealed_payload = self._sealed_time_log(
|
||||
login, snapshot["session_id"], snapshot["identity"],
|
||||
snapshot["actual_minutes"], snapshot["status"],
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET session_id = ?, identity = ?, actual_minutes = ?, status = 'sealed' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(sealed_session, sealed_identity, sealed_payload, login,
|
||||
snapshot["stored_session"], snapshot["stored_identity"]),
|
||||
)
|
||||
return {**snapshot, "stored_session": sealed_session, "stored_identity": sealed_identity}
|
||||
|
||||
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
|
||||
"""Claim one recap item for upstream logging, returning its current state."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT actual_minutes, status FROM today_time_logs "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, identity, actual_minutes, status FROM today_time_logs WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall()
|
||||
match = None
|
||||
for row in rows:
|
||||
candidate, legacy = self._time_log_snapshot(login, row)
|
||||
if candidate["session_id"] == session_id and candidate["identity"] == identity:
|
||||
match = self._migrate_time_log(connection, login, candidate) if legacy else candidate
|
||||
break
|
||||
if match is None:
|
||||
sealed_session, sealed_identity, sealed_payload = self._sealed_time_log(
|
||||
login, session_id, identity, actual_minutes, "pending"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
|
||||
"VALUES (?, ?, ?, ?, 'pending')",
|
||||
(login, session_id, identity, actual_minutes),
|
||||
"VALUES (?, ?, ?, ?, 'sealed')",
|
||||
(login, sealed_session, sealed_identity, sealed_payload),
|
||||
)
|
||||
return "claimed"
|
||||
if row[0] != actual_minutes:
|
||||
if match["actual_minutes"] != actual_minutes:
|
||||
raise ValueError("logged recap time cannot be changed")
|
||||
if row[1] == "failed":
|
||||
if match["status"] == "failed":
|
||||
sealed_payload = self._sealed_time_log(
|
||||
login, session_id, identity, actual_minutes, "pending"
|
||||
)[2]
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = 'pending' "
|
||||
"UPDATE today_time_logs SET actual_minutes = ?, status = 'sealed' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
(sealed_payload, login, match["stored_session"], match["stored_identity"]),
|
||||
)
|
||||
return "claimed"
|
||||
return row[1]
|
||||
return match["status"]
|
||||
|
||||
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = ? "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
|
||||
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, identity, actual_minutes, status FROM today_time_logs WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
match, legacy = self._time_log_snapshot(login, row)
|
||||
if match["session_id"] != session_id or match["identity"] != identity or match["status"] != "pending":
|
||||
continue
|
||||
if legacy:
|
||||
match = self._migrate_time_log(connection, login, match)
|
||||
sealed_payload = self._sealed_time_log(
|
||||
login, session_id, identity, match["actual_minutes"],
|
||||
"logged" if succeeded else "failed",
|
||||
)[2]
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET actual_minutes = ?, status = 'sealed' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(sealed_payload, login, match["stored_session"], match["stored_identity"]),
|
||||
)
|
||||
return
|
||||
|
||||
def apply(
|
||||
self,
|
||||
@@ -384,7 +561,7 @@ class TodayStore:
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
snapshot, _legacy = self._snapshot(row, login)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
@@ -417,19 +594,22 @@ class TodayStore:
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized_ids = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
sealed = self._sealed_plan(login, {
|
||||
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
||||
snapshot.get("plan_date"), snapshot.get("timezone")),
|
||||
(login, revision, sealed, None, "{}", None, None),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized_ids, serialized_estimates, login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = NULL, "
|
||||
"estimates = '{}', plan_date = NULL, timezone = NULL WHERE login = ?",
|
||||
(revision, sealed, login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
result = {
|
||||
@@ -452,7 +632,7 @@ class TodayStore:
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
snapshot, _legacy = self._snapshot(row, login)
|
||||
ids = list(snapshot["ids"])
|
||||
capacity_minutes = snapshot["capacity_minutes"]
|
||||
estimates = dict(snapshot["estimates"])
|
||||
@@ -581,19 +761,21 @@ class TodayStore:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
sealed = self._sealed_plan(login, {
|
||||
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
|
||||
"plan_date": plan_date, "timezone": timezone,
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
||||
(login, revision, sealed, None, "{}", None, None),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
||||
"plan_date = ?, timezone = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
||||
(revision, sealed, None, "{}", None, None, login),
|
||||
)
|
||||
result = {
|
||||
"revision": revision,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +16,61 @@ from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(390, 844)])
|
||||
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake.assigned_issue_numbers = []
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("First task release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
sheet = page.locator("#mobile-first-task")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_focused()
|
||||
for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#mobile-first-task-find").click()
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||
page.locator("#close-find-work").click()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_disabled()
|
||||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
tmp_path: Path, width: int, height: int
|
||||
@@ -29,6 +85,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
launch_transfer_events: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
try:
|
||||
@@ -38,6 +95,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
cdp = context.new_cdp_session(page)
|
||||
cdp.send("Network.enable")
|
||||
cdp.send(
|
||||
"Network.emulateNetworkConditions",
|
||||
{
|
||||
"offline": False,
|
||||
"latency": 100,
|
||||
"downloadThroughput": 48 * 1024,
|
||||
"uploadThroughput": 48 * 1024,
|
||||
"connectionType": "cellular3g",
|
||||
},
|
||||
)
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
@@ -53,9 +122,16 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: workspace_requests.append(request.url)
|
||||
if "feature-today-timer-" in request.url
|
||||
else None,
|
||||
lambda request: (
|
||||
workspace_requests.append(request.url),
|
||||
launch_transfer_events.append("workspace-requested"),
|
||||
)
|
||||
if "feature-today-timer-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"requestfinished",
|
||||
lambda request: launch_transfer_events.append("core-finished")
|
||||
if "/runtime-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
@@ -69,6 +145,9 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
assert launch_transfer_events.index("workspace-requested") < (
|
||||
launch_transfer_events.index("core-finished")
|
||||
), launch_transfer_events
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
initial_live_requests = len(live_requests)
|
||||
@@ -124,16 +203,47 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
expect(page.locator("#device-setup-sheet")).to_be_visible()
|
||||
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
expect(page.locator("#close-device-setup")).to_be_focused()
|
||||
page.keyboard.press("Shift+Tab")
|
||||
expect(page.locator("#clear-private-device-data")).to_be_focused()
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-device-setup")).to_be_focused()
|
||||
for selector in ("#clear-device-caches", "#clear-private-device-data"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-device-setup").click()
|
||||
page.go_back()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
||||
expect(page.locator("#open-device-setup")).to_be_focused()
|
||||
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).not_to_have_attribute("inert", "")
|
||||
expect(dock).not_to_have_attribute("inert", "")
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
expect(page.locator("#active-devices-status")).to_contain_text("active device")
|
||||
expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
return document.activeElement === controls.at(-1);
|
||||
}
|
||||
"""
|
||||
)
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
@@ -143,9 +253,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).to_be_focused()
|
||||
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).not_to_have_attribute("inert", "")
|
||||
expect(dock).not_to_have_attribute("inert", "")
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
@@ -158,6 +274,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
attempts: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
|
||||
def interrupt_once(route):
|
||||
attempts.append(route.request.url)
|
||||
if len(attempts) == 1:
|
||||
route.abort("failed")
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
|
||||
page.locator('input[name="device_label"]').fill("Workspace recovery release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.wait_for_timeout(1500)
|
||||
resources = page.evaluate(
|
||||
"performance.getEntriesByType('resource').map(entry => entry.name)"
|
||||
)
|
||||
state = {
|
||||
"attempts": attempts,
|
||||
"resources": resources,
|
||||
"action": page.locator("#my-work-action-status").text_content(),
|
||||
"retry_hidden": page.locator("#retry-workspace").is_hidden(),
|
||||
"work": page.locator("#my-work-status").text_content(),
|
||||
}
|
||||
assert len(attempts) == 2, state
|
||||
assert "2" in (state["work"] or ""), state
|
||||
expect(page.locator("#retry-workspace")).to_be_hidden()
|
||||
expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
|
||||
assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile sign-out journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_private_work_before_mobile_sign_out(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
delete_requests: list[str] = []
|
||||
browser_errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: delete_requests.append(request.url)
|
||||
if request.method == "DELETE" and "/api/v1/session" in request.url
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Sign-out review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("localStorage.setItem('stackchain.release-test-draft', 'unsynced')")
|
||||
|
||||
launcher = page.locator("#sign-out")
|
||||
page.locator("#app-menu-toggle").click()
|
||||
launcher.click()
|
||||
sheet = page.locator("#sign-out-review-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
|
||||
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
|
||||
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
|
||||
assert page.evaluate("document.querySelector('main').inert") is True
|
||||
panel = page.locator(".sign-out-review-panel").bounding_box()
|
||||
assert panel and panel["height"] <= 844
|
||||
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert delete_requests == []
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
assert page.evaluate("document.querySelector('main').inert") is False
|
||||
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
|
||||
assert delete_requests == []
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -530,11 +530,20 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
||||
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||
|
||||
page.evaluate("""() => {
|
||||
const outbox = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') ||
|
||||
'{"version":2,"items":[]}');
|
||||
outbox.items.push({
|
||||
id: 'already-admitted-blocker', operationId: 'already-admitted-blocker',
|
||||
kind: 'issue-comment', repository: 'acme/mobile', number: 41,
|
||||
body: 'Blocked after reload waiting for the design owner', ownerLogin: 'timmy',
|
||||
status: 'queued', queuedAt: Date.now()
|
||||
});
|
||||
localStorage.setItem('stackchain.authored-outbox.v1', JSON.stringify(outbox));
|
||||
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
|
||||
version: 1,
|
||||
drafts: {
|
||||
'issue:acme/mobile:41:': {
|
||||
body: 'Blocked waiting for the design owner',
|
||||
body: 'Blocked after reload waiting for the design owner',
|
||||
operation_id: 'already-admitted-blocker',
|
||||
blocker_pending: true,
|
||||
blocker_until: '2000-01-02T03:04:00.000Z'
|
||||
@@ -574,10 +583,23 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
||||
})""")
|
||||
assert state["today"] == ["issue:acme/mobile:42:"]
|
||||
assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00")
|
||||
comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["number"] == 41
|
||||
assert comments[0]["body"] == "Blocked waiting for the design owner"
|
||||
queued_comments = [
|
||||
item for item in state["outbox"]["items"]
|
||||
if item["kind"] == "issue-comment"
|
||||
and item["number"] == 41
|
||||
and item["body"] == "Blocked after reload waiting for the design owner"
|
||||
]
|
||||
delivered_comments = [
|
||||
{"number": number, "body": body}
|
||||
for number, body in fake.comments
|
||||
if number == 41 and body == "Blocked after reload waiting for the design owner"
|
||||
]
|
||||
assert len(queued_comments) <= 1
|
||||
assert len(delivered_comments) <= 1
|
||||
assert queued_comments or delivered_comments
|
||||
receipt = (delivered_comments or queued_comments)[0]
|
||||
assert receipt["number"] == 41
|
||||
assert receipt["body"] == "Blocked after reload waiting for the design owner"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
|
||||
@@ -58,6 +58,9 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||
storage:localStorage,getLogin:()=> 'timmy',
|
||||
share:payload=>navigator.share(payload),
|
||||
copy:text=>navigator.clipboard.writeText(text),
|
||||
createOperationId:()=> 'release-summary-op',
|
||||
resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
|
||||
enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
|
||||
});
|
||||
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__summaryTest={summary,view};
|
||||
@@ -80,6 +83,42 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#today-summary-destination").fill("acme/mobile#41")
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
|
||||
return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#today-summary-target")).to_contain_text(
|
||||
"acme/mobile#41 · issue · Daily status"
|
||||
)
|
||||
expect(page.locator("#post-today-summary")).to_be_enabled()
|
||||
page.evaluate(
|
||||
"window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
|
||||
)
|
||||
expect(sheet).to_be_hidden()
|
||||
assert page.evaluate("window.__queuedSummary") == {
|
||||
"kind": "search-reply",
|
||||
"repository": "acme/mobile",
|
||||
"number": 41,
|
||||
"targetKind": "issue",
|
||||
"body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
|
||||
"operationId": "release-summary-op",
|
||||
}
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
window.__summaryTest.view.open(
|
||||
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
|
||||
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
expect(sheet).to_be_visible()
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
|
||||
@@ -63,6 +63,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
|
||||
@@ -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-v121" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
@@ -17,6 +17,7 @@ class Target {
|
||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||
let purgeCalls=0, purgeError='';
|
||||
let inventory={recordCount:0, unavailable:false};
|
||||
let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''};
|
||||
const localStorage={
|
||||
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
||||
get length(){return this.values.size;},
|
||||
@@ -27,11 +28,16 @@ const caches={
|
||||
async keys(){return Array.from(this.values);},
|
||||
async delete(key){return this.values.delete(key);},
|
||||
};
|
||||
const storageManager={
|
||||
estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024}),
|
||||
persisted:async()=>{persistence.persistedCalls++;if(persistence.error)throw new Error(persistence.error);return persistence.persisted;},
|
||||
persist:async()=>{persistence.persistCalls++;persistence.persisted=persistence.persistResult;return persistence.persistResult;},
|
||||
};
|
||||
const controller=createDeviceStorage({
|
||||
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
||||
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
||||
inspectPrivateDatabases:async()=>inventory,
|
||||
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
|
||||
storageManager,
|
||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||
});
|
||||
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -49,7 +55,104 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
|
||||
|
||||
assert result == {
|
||||
"summary": "2 MB of 10 MB browser storage used.",
|
||||
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_checks_storage_protection_without_requesting_permission():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
process.stdout.write(JSON.stringify({
|
||||
readiness:await controller.persistenceReadiness(),
|
||||
persistedCalls:persistence.persistedCalls,
|
||||
persistCalls:persistence.persistCalls,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "Offline work uses best-effort browser storage and may be removed under storage pressure.",
|
||||
},
|
||||
"persistedCalls": 1,
|
||||
"persistCalls": 0,
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_request_confirms_storage_is_protected_before_claiming_success():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
persistence.persistResult=true;
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({
|
||||
readiness,
|
||||
persistedCalls:persistence.persistedCalls,
|
||||
persistCalls:persistence.persistCalls,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "complete",
|
||||
"detail": "Offline work is protected from automatic browser storage cleanup.",
|
||||
},
|
||||
"persistedCalls": 2,
|
||||
"persistCalls": 1,
|
||||
"detail": "Storage protection: Protected · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_denied_request_remains_retryable_and_does_not_claim_protection():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({readiness,persistCalls:persistence.persistCalls}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
|
||||
},
|
||||
"persistCalls": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_denied_request_keeps_setup_and_storage_panel_in_the_same_state_when_recheck_fails():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
storageManager.persist=async()=>{persistence.persistCalls++;persistence.error='check blocked';return false;};
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
|
||||
},
|
||||
"detail": "Storage protection: Denied · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_request_is_retryable_and_updates_storage_status_without_data_loss():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
storageManager.persist=async()=>{persistence.persistCalls++;throw new Error('blocked');};
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent,persistCalls:persistence.persistCalls}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "Storage protection request failed. Offline work still uses best-effort storage; retry when ready.",
|
||||
},
|
||||
"detail": "Storage protection: Request failed · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
"persistCalls": 1,
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +172,7 @@ process.stdout.write(JSON.stringify({
|
||||
"caches": ["other-app"],
|
||||
"storage": ["stackchain.draft.1", "other.preference"],
|
||||
"status": "Cached app copies cleared. Private work was kept.",
|
||||
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +193,7 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
|
||||
},
|
||||
"calls": 1,
|
||||
"status": "All Stackchain private data was cleared from this device.",
|
||||
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +215,7 @@ process.stdout.write(JSON.stringify({
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +235,7 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
|
||||
SCRIPT_PRELOAD = re.compile(r'<link rel="preload" as="script" href="([^"]+)">')
|
||||
|
||||
|
||||
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
@@ -35,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
(changed_frontend / "session.js").write_text(
|
||||
(changed_frontend / "session.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
@@ -48,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
@@ -74,6 +75,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createSignOutReview" not in first.runtime_bytes
|
||||
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
|
||||
assert b"function createDetailDefer" not in first.runtime_bytes
|
||||
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
@@ -119,6 +122,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||
|
||||
|
||||
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
||||
build = build_frontend(FRONTEND)
|
||||
workspace = build.feature_bundles["today-timer"]
|
||||
|
||||
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
|
||||
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
|
||||
assert len(build.runtime_gzip_bytes) <= 100 * 1024
|
||||
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
|
||||
|
||||
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
||||
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
||||
|
||||
|
||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||
build = build_frontend(FRONTEND)
|
||||
bundles = {"core.js": build.runtime_bytes}
|
||||
|
||||
@@ -2,7 +2,132 @@ import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
|
||||
from src.idempotency import IdempotencyLedger
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"i" * 32
|
||||
|
||||
|
||||
def test_authored_payloads_are_encrypted_at_rest_and_replay_after_restart(tmp_path):
|
||||
database = tmp_path / "idempotency.sqlite3"
|
||||
fingerprint_canary = "private-issue-body-canary"
|
||||
response_canary = "private-gitea-response-canary"
|
||||
ledger = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
encryption_key=PRIVATE_KEY,
|
||||
)
|
||||
|
||||
assert ledger.reserve("create-201", ("issue-create", fingerprint_canary)).state == "reserved"
|
||||
ledger.complete("create-201", {"title": response_canary})
|
||||
|
||||
persisted = database.read_bytes()
|
||||
assert fingerprint_canary.encode() not in persisted
|
||||
assert response_canary.encode() not in persisted
|
||||
with sqlite3.connect(database) as connection:
|
||||
fingerprint, response = connection.execute(
|
||||
"SELECT fingerprint, response_json FROM idempotency_operations WHERE key = ?",
|
||||
("create-201",),
|
||||
).fetchone()
|
||||
assert fingerprint.startswith("v1:")
|
||||
assert response.startswith("v1:")
|
||||
|
||||
reopened = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
encryption_key=PRIVATE_KEY,
|
||||
)
|
||||
replay = reopened.reserve("create-201", ("issue-create", fingerprint_canary))
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"title": response_canary}
|
||||
|
||||
|
||||
def test_legacy_plaintext_completed_row_is_migrated_without_losing_replay(tmp_path):
|
||||
database = tmp_path / "legacy.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
encryption_key=PRIVATE_KEY,
|
||||
clock=lambda: 100.0,
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, response_json, created_at, completed_at) "
|
||||
"VALUES (?, ?, 'completed', ?, ?, ?)",
|
||||
(
|
||||
"legacy-comment",
|
||||
'["issue-comment","stackchain/api",7,"legacy secret"]',
|
||||
'{"id":42,"body":"legacy response"}',
|
||||
90.0,
|
||||
95.0,
|
||||
),
|
||||
)
|
||||
|
||||
replay = ledger.reserve(
|
||||
"legacy-comment",
|
||||
("issue-comment", "stackchain/api", 7, "legacy secret"),
|
||||
)
|
||||
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"id": 42, "body": "legacy response"}
|
||||
with sqlite3.connect(database) as connection:
|
||||
fingerprint, response = connection.execute(
|
||||
"SELECT fingerprint, response_json FROM idempotency_operations WHERE key = ?",
|
||||
("legacy-comment",),
|
||||
).fetchone()
|
||||
assert fingerprint.startswith("v1:")
|
||||
assert response.startswith("v1:")
|
||||
|
||||
|
||||
def test_wrong_key_fails_closed_before_a_completed_result_can_replay(tmp_path):
|
||||
database = tmp_path / "wrong-key.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "secret")
|
||||
assert ledger.reserve("comment-7", fingerprint).state == "reserved"
|
||||
ledger.complete("comment-7", {"id": 42})
|
||||
|
||||
wrong_key = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, encryption_key=b"x" * 32
|
||||
)
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
wrong_key.reserve("comment-7", fingerprint)
|
||||
|
||||
|
||||
def test_completed_ciphertext_cannot_be_substituted_between_operation_keys(tmp_path):
|
||||
database = tmp_path / "substitution.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
fingerprint = ("issue-create", "stackchain/api", "same authored request")
|
||||
for key, number in (("create-a", 41), ("create-b", 42)):
|
||||
assert ledger.reserve(key, fingerprint).state == "reserved"
|
||||
ledger.complete(key, {"number": number})
|
||||
with sqlite3.connect(database) as connection:
|
||||
responses = dict(
|
||||
connection.execute(
|
||||
"SELECT key, response_json FROM idempotency_operations"
|
||||
).fetchall()
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET response_json = ? WHERE key = ?",
|
||||
(responses["create-b"], "create-a"),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
ledger.reserve("create-a", fingerprint)
|
||||
|
||||
|
||||
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
@@ -5,6 +6,10 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src.later_store import LaterStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"l" * 32
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -65,6 +70,105 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_later_plans_encrypt_private_content_and_authenticate_the_account(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
item_id = "issue:private/repository:363:"
|
||||
wake_at = "2026-08-10T09:17:00.000Z"
|
||||
store = LaterStore(path, encryption_key=PRIVATE_KEY)
|
||||
|
||||
expected = store.apply(
|
||||
"timmy", "op-private", "defer", item_id,
|
||||
wake_at=wake_at, handoff="today",
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
records = connection.execute(
|
||||
"SELECT records FROM later_plans WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
stored_item_id = connection.execute(
|
||||
"SELECT item_id FROM later_item_revisions WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert records.startswith("v1:")
|
||||
assert stored_item_id.startswith("v1:")
|
||||
assert item_id not in records + stored_item_id
|
||||
assert wake_at not in records
|
||||
assert "today" not in records
|
||||
assert LaterStore(path, encryption_key=PRIVATE_KEY).get("timmy") == expected
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
("alexander", 1, records),
|
||||
)
|
||||
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||
store.get("alexander")
|
||||
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||
LaterStore(path, encryption_key=b"x" * 32).get("timmy")
|
||||
|
||||
|
||||
def test_later_read_atomically_migrates_legacy_private_content_without_new_revision(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
item_id = "issue:legacy/private:17:"
|
||||
records = {item_id: {"wake_at": "2026-08-20T08:00:00.000Z", "handoff": "today"}}
|
||||
store = LaterStore(path, encryption_key=PRIVATE_KEY)
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
("timmy", 7, json.dumps(records)),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
("timmy", item_id, 7),
|
||||
)
|
||||
|
||||
assert store.get("timmy") == {"revision": 7, "records": records}
|
||||
with sqlite3.connect(path) as connection:
|
||||
migrated_plan = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = 'timmy'"
|
||||
).fetchone()
|
||||
migrated_revision = connection.execute(
|
||||
"SELECT revision, item_id FROM later_item_revisions WHERE login = 'timmy'"
|
||||
).fetchone()
|
||||
assert migrated_plan[0] == migrated_revision[0] == 7
|
||||
assert migrated_plan[1].startswith("v1:")
|
||||
assert migrated_revision[1].startswith("v1:")
|
||||
assert item_id not in migrated_plan[1] + migrated_revision[1]
|
||||
|
||||
|
||||
def test_duplicate_write_migrates_legacy_later_content_without_advancing_revision(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
item_id = "issue:legacy/private:18:"
|
||||
records = {item_id: "2026-08-21T08:00:00.000Z"}
|
||||
store = LaterStore(path, encryption_key=PRIVATE_KEY)
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
("timmy", 4, json.dumps(records)),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
("timmy", item_id, 4),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
("timmy", "already-confirmed", 1.0),
|
||||
)
|
||||
|
||||
result = store.apply(
|
||||
"timmy", "already-confirmed", "restore", item_id, base_revision=4,
|
||||
)
|
||||
|
||||
assert result == {"revision": 4, "records": records}
|
||||
with sqlite3.connect(path) as connection:
|
||||
stored = connection.execute(
|
||||
"SELECT records FROM later_plans WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
stored_item_id = connection.execute(
|
||||
"SELECT item_id FROM later_item_revisions WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert stored.startswith("v1:")
|
||||
assert stored_item_id.startswith("v1:")
|
||||
|
||||
|
||||
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
|
||||
|
||||
@@ -435,5 +435,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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -81,10 +81,112 @@ const controller = status.createRefreshController({{
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
function element(name) {{
|
||||
return {{name, hidden:false, inert:false, listeners:{{}}, focused:0,
|
||||
addEventListener(type, fn) {{ this.listeners[type] = fn; }},
|
||||
focus() {{ this.focused += 1; }},
|
||||
}};
|
||||
}}
|
||||
const trigger = element('trigger');
|
||||
trigger.attrs = {{}};
|
||||
trigger.setAttribute = (name, value) => trigger.attrs[name] = value;
|
||||
const close = element('close');
|
||||
const refresh = element('refresh');
|
||||
const back = element('return');
|
||||
const sheet = element('sheet');
|
||||
sheet.hidden = true;
|
||||
sheet.querySelectorAll = () => [close, refresh, back];
|
||||
const header = element('header');
|
||||
const main = element('main');
|
||||
const listeners = {{}};
|
||||
const history = {{state:null, pushes:0, backs:0,
|
||||
pushState(state) {{ this.state=state; this.pushes += 1; }},
|
||||
back() {{ this.backs += 1; }},
|
||||
}};
|
||||
const timerView = {{begins:[], finishes:0,
|
||||
beginDetour(reason) {{ this.begins.push(reason); return {{identity:'issue:r:42', reason}}; }},
|
||||
finishDetour() {{ this.finishes += 1; return {{resumed:true}}; }},
|
||||
}};
|
||||
const paused = element('paused');
|
||||
paused.hidden = true;
|
||||
const controller = status.createSheetController({{
|
||||
sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused,
|
||||
timerView, history, historyTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
|
||||
escapeTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
|
||||
backgroundElements:[header, main],
|
||||
}});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const opened = {{hidden:sheet.hidden, expanded:trigger.attrs['aria-expanded'], paused:paused.hidden, inert:[header.inert, main.inert],
|
||||
begins:timerView.begins, pushes:history.pushes, closeFocused:close.focused}};
|
||||
let prevented = 0;
|
||||
listeners.keydown({{key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++}});
|
||||
const trapped = {{prevented, closeFocused:close.focused}};
|
||||
controller.close();
|
||||
const requested = {{backs:history.backs, finishes:timerView.finishes}};
|
||||
history.state = null;
|
||||
listeners.popstate({{state:null}});
|
||||
process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidden:sheet.hidden, paused:paused.hidden,
|
||||
expanded:trigger.attrs['aria-expanded'], inert:[header.inert, main.inert], finishes:timerView.finishes,
|
||||
triggerFocused:trigger.focused}}}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result == {
|
||||
"opened": {
|
||||
"hidden": False,
|
||||
"expanded": "true",
|
||||
"paused": False,
|
||||
"inert": [True, True],
|
||||
"begins": ["live-data-status"],
|
||||
"pushes": 1,
|
||||
"closeFocused": 1,
|
||||
},
|
||||
"trapped": {"prevented": 1, "closeFocused": 2},
|
||||
"requested": {"backs": 1, "finishes": 0},
|
||||
"closed": {
|
||||
"hidden": True,
|
||||
"paused": True,
|
||||
"expanded": "false",
|
||||
"inert": [False, False],
|
||||
"finishes": 1,
|
||||
"triggerFocused": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_without_running_today_hides_return_and_does_not_resume():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
function element(hidden=false) {{ return {{hidden, inert:false, listeners:{{}}, attrs:{{}},
|
||||
addEventListener(name, fn) {{ this.listeners[name]=fn; }}, focus() {{}},
|
||||
setAttribute(name, value) {{ this.attrs[name]=value; }}, querySelectorAll() {{ return []; }} }}; }}
|
||||
const sheet=element(true), trigger=element(), close=element(), returnButton=element();
|
||||
let finishes=0;
|
||||
const controller=status.createSheetController({{
|
||||
sheet,trigger,closeButton:close,returnButton,
|
||||
timerView:{{beginDetour:()=>null,finishDetour:()=>finishes++}},
|
||||
}});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const opened={{returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']}};
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{opened, finishes}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"opened": {"returnHidden": True, "expanded": "true"},
|
||||
"finishes": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
html = HTML.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
status_source = STATUS.read_text()
|
||||
|
||||
assert 'id="open-live-data-status"' in html
|
||||
assert 'aria-controls="live-data-status-sheet"' in html
|
||||
@@ -92,10 +194,15 @@ def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
assert 'aria-labelledby="live-data-status-heading"' in html
|
||||
assert 'id="live-data-status-feeds"' in html
|
||||
assert 'id="refresh-live-data"' in html
|
||||
assert 'id="return-from-live-data-status"' in html
|
||||
assert 'id="live-data-status-today-paused"' in html
|
||||
assert '<script src="static/live-data-status.js"></script>' in html
|
||||
assert ".live-data-status-panel" in css
|
||||
assert "width:min(560px,100%)" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "liveDataStatus.describe" in dashboard
|
||||
assert "liveDataStatus.createRefreshController" in dashboard
|
||||
assert "liveDataStatus.mount" in dashboard
|
||||
assert "createSheetController" in status_source
|
||||
assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source
|
||||
assert "contextPoller.getState()" in dashboard
|
||||
@@ -256,4 +256,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-v121" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
@@ -45,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-v121" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -10,7 +10,7 @@ def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createMobileDeviceSetup = require(__MODULE__);
|
||||
class FakeTarget {
|
||||
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
|
||||
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; this.inert = false; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||
focus() { state.focused = this; }
|
||||
@@ -24,10 +24,12 @@ const closeButton = new FakeTarget();
|
||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||
const installButton = new FakeTarget();
|
||||
const offlineButton = new FakeTarget();
|
||||
const protectionButton = new FakeTarget();
|
||||
const pushButton = new FakeTarget();
|
||||
const deadlineButton = new FakeTarget();
|
||||
const installStatus = new FakeTarget();
|
||||
const offlineStatus = new FakeTarget();
|
||||
const protectionStatus = new FakeTarget();
|
||||
const pushStatus = new FakeTarget();
|
||||
const deadlineStatus = new FakeTarget();
|
||||
const readyStatus = new FakeTarget();
|
||||
@@ -37,6 +39,15 @@ const promptSummary = new FakeTarget();
|
||||
const promptLauncher = new FakeTarget();
|
||||
const promptDismiss = new FakeTarget();
|
||||
const returnButton = new FakeTarget();
|
||||
const backgroundHeader = new FakeTarget();
|
||||
const backgroundMain = new FakeTarget(); backgroundMain.inert = true;
|
||||
sheet.querySelectorAll = () => [closeButton, installButton, offlineButton, returnButton];
|
||||
const historyTarget = new FakeTarget();
|
||||
const history = {
|
||||
state:{section:'home'},
|
||||
pushState(state) { this.state = state; },
|
||||
back() { this.state = {section:'home'}; historyTarget.dispatch('popstate', {state:this.state}); },
|
||||
};
|
||||
let now = 1000;
|
||||
const promptStorage = {
|
||||
values:{},
|
||||
@@ -46,14 +57,17 @@ const promptStorage = {
|
||||
let readiness = {
|
||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||
protection:{state:'incomplete', detail:'Offline work uses best-effort browser storage.'},
|
||||
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
||||
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
||||
};
|
||||
const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
launcher, closeButton, sheet, installButton, offlineButton, protectionButton, pushButton, deadlineButton,
|
||||
installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
returnButton, isMobile:() => true,
|
||||
backgroundElements:[backgroundHeader, backgroundMain],
|
||||
history, historyTarget,
|
||||
timerView:{
|
||||
beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; },
|
||||
finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; },
|
||||
@@ -62,6 +76,7 @@ const setup = createMobileDeviceSetup({
|
||||
getReadiness:() => { state.events.push('readiness'); return readiness; },
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
protectStorage:async () => { state.protectionCalls = (state.protectionCalls || 0) + 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
enableDeadline:async () => { state.deadlineCalls += 1; },
|
||||
});
|
||||
@@ -90,11 +105,33 @@ process.stdout.write(JSON.stringify({
|
||||
"install": "Stackchain is installed.",
|
||||
"offline": "Offline work is off.",
|
||||
"push": "Notifications are unavailable.",
|
||||
"summary": "1 of 2 available steps ready.",
|
||||
"summary": "1 of 3 available steps ready.",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_storage_protection_runs_only_from_its_explicit_setup_action():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
const callsOnOpen=state.protectionCalls || 0;
|
||||
readiness.protection={state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.'};
|
||||
await protectionButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
callsOnOpen,
|
||||
callsAfterAction:state.protectionCalls,
|
||||
detail:protectionStatus.textContent,
|
||||
hidden:protectionButton.hidden,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"callsOnOpen": 0,
|
||||
"callsAfterAction": 1,
|
||||
"detail": "Offline work is protected from automatic browser storage cleanup.",
|
||||
"hidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
|
||||
result = run_scenario("""
|
||||
process.stdout.write(JSON.stringify({
|
||||
@@ -106,7 +143,7 @@ process.stdout.write(JSON.stringify({
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"summary": "1 of 2 steps complete",
|
||||
"summary": "1 of 3 steps complete",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
@@ -146,6 +183,7 @@ process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil -
|
||||
def test_setup_action_rechecks_real_state_before_marking_step_ready():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
readiness.protection = {state:'complete', detail:'Offline work is protected.'};
|
||||
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
|
||||
await offlineButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
@@ -179,6 +217,60 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {"hidden": True, "launcherFocused": True}
|
||||
|
||||
|
||||
def test_open_setup_contains_focus_and_restores_background_inert_state():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
state.focused = returnButton;
|
||||
let preventedForward = false;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab', target:returnButton, preventDefault(){preventedForward=true;}});
|
||||
const forwardFocus = state.focused === closeButton;
|
||||
state.focused = closeButton;
|
||||
let preventedReverse = false;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true, target:closeButton, preventDefault(){preventedReverse=true;}});
|
||||
const reverseFocus = state.focused === returnButton;
|
||||
const inertWhileOpen = [backgroundHeader.inert, backgroundMain.inert];
|
||||
await closeButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
preventedForward, forwardFocus, preventedReverse, reverseFocus,
|
||||
inertWhileOpen,
|
||||
inertAfterClose:[backgroundHeader.inert, backgroundMain.inert],
|
||||
launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"preventedForward": True,
|
||||
"forwardFocus": True,
|
||||
"preventedReverse": True,
|
||||
"reverseFocus": True,
|
||||
"inertWhileOpen": [True, True],
|
||||
"inertAfterClose": [False, True],
|
||||
"launcherFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_browser_back_closes_setup_and_restores_launcher_focus():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
const marked = history.state.deviceSetup === true;
|
||||
history.back();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({
|
||||
marked,
|
||||
hidden:sheet.hidden,
|
||||
backgroundReleased:backgroundHeader.inert === false,
|
||||
launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"marked": True,
|
||||
"hidden": True,
|
||||
"backgroundReleased": True,
|
||||
"launcherFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once():
|
||||
result = run_scenario("""
|
||||
state.events = [];
|
||||
@@ -227,7 +319,27 @@ process.stdout.write(JSON.stringify({
|
||||
"calls": 1,
|
||||
"detail": "Deadline reminders enabled for 08:00 local time.",
|
||||
"hidden": True,
|
||||
"summary": "3 of 4 available steps ready.",
|
||||
"summary": "3 of 5 available steps ready.",
|
||||
}
|
||||
|
||||
|
||||
def test_blocked_notification_step_becomes_a_check_again_action():
|
||||
result = run_scenario("""
|
||||
readiness.push = {
|
||||
state:'blocked',
|
||||
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
|
||||
actionLabel:'Check again',
|
||||
};
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
await pushButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({label:pushButton.textContent,detail:pushStatus.textContent,calls:state.pushCalls,hidden:pushButton.hidden}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"label": "Check again",
|
||||
"detail": "Notifications are blocked. Allow them in browser settings, then check again.",
|
||||
"calls": 1,
|
||||
"hidden": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +360,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert 'id="device-setup-today-detour"' in html
|
||||
assert 'id="return-from-device-setup"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "protection", "push", "deadline"))
|
||||
assert 'id="device-setup-protection-status"' in html
|
||||
assert 'id="device-storage-summary"' in html
|
||||
assert 'id="device-storage-detail"' in html
|
||||
assert 'id="clear-device-caches"' in html
|
||||
@@ -260,12 +373,17 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "timerView," in dashboard
|
||||
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text()
|
||||
assert "createDeviceStorage.mount(document)" in dashboard
|
||||
assert "const deviceStorage = createDeviceStorage.mount(document)" in dashboard
|
||||
assert "storageProtectionReadiness:() => deviceStorage.persistenceReadiness()" in dashboard
|
||||
assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard
|
||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "notificationReadiness:() => pushController?.notificationReadiness()" in dashboard
|
||||
assert "controller.recoverPermission('updates')" in dashboard
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v121" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-first-task.js"
|
||||
|
||||
|
||||
def run_node(body: str) -> dict:
|
||||
script = f"""
|
||||
const createFirstTask = require({json.dumps(str(CONTROLLER))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners = {{}}; this.disabled = false; this.hidden = false; this.open = false; this.textContent = ''; this.focused = false; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
||||
showModal() {{ this.open = true; }}
|
||||
close() {{ this.open = false; this.listeners.close?.(); }}
|
||||
focus() {{ this.focused = true; }}
|
||||
}}
|
||||
{body}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
||||
const sheet = new Element();
|
||||
let login = 'Timmy';
|
||||
let hasWork = false;
|
||||
let todayActive = false;
|
||||
const controller = createFirstTask({
|
||||
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
|
||||
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
|
||||
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
|
||||
});
|
||||
const before = [controller.required(), controller.open(), sheet.open];
|
||||
hasWork = true;
|
||||
const awaitingStart = controller.refresh();
|
||||
const stillRequired = controller.required();
|
||||
todayActive = true;
|
||||
const completed = controller.refresh();
|
||||
const timmyRequired = controller.required();
|
||||
login = 'alexander'; hasWork = false; todayActive = false;
|
||||
const alexanderRequired = controller.required();
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": [True, True, True],
|
||||
"awaitingStart": "awaiting-start",
|
||||
"stillRequired": True,
|
||||
"completed": "completed",
|
||||
"sheetOpen": False,
|
||||
"timmyRequired": False,
|
||||
"alexanderRequired": True,
|
||||
"values": [["stackchain.first-task.v1:timmy", "complete"]],
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_resumes_with_finish_starting_guidance():
|
||||
result = run_node(
|
||||
"""
|
||||
const sheet = new Element();
|
||||
const title = new Element();
|
||||
const findButton = new Element();
|
||||
const createButton = new Element();
|
||||
const status = new Element();
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => true,
|
||||
isTodayActive:() => false, isOnline:() => true, mediaQuery:{matches:true}, sheet, title,
|
||||
findButton, createButton, setupButton:new Element(), closeButton:new Element(), status,
|
||||
onFind() {}, onCreate() {}, onSetup() {},
|
||||
});
|
||||
controller.open();
|
||||
process.stdout.write(JSON.stringify({
|
||||
required:controller.required(), title:title.textContent, status:status.textContent,
|
||||
find:findButton.textContent, create:createButton.textContent,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"required": True,
|
||||
"title": "Finish starting your first task",
|
||||
"status": "Your task is ready. Start it from Find or create and start another task.",
|
||||
"find": "Find & start",
|
||||
"create": "Create & start",
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||
result = run_node(
|
||||
"""
|
||||
const calls = [];
|
||||
const sheet = new Element();
|
||||
const findButton = new Element();
|
||||
const createButton = new Element();
|
||||
const setupButton = new Element();
|
||||
const closeButton = new Element();
|
||||
const status = new Element();
|
||||
let online = false;
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => false,
|
||||
isOnline:() => online, mediaQuery:{matches:true}, sheet, findButton, createButton,
|
||||
setupButton, closeButton, status,
|
||||
onFind:() => calls.push('find'), onCreate:() => calls.push('create'), onSetup:() => calls.push('setup'),
|
||||
});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const offline = {findDisabled:findButton.disabled, createDisabled:createButton.disabled, status:status.textContent, createFocused:createButton.focused};
|
||||
findButton.click();
|
||||
createButton.click();
|
||||
controller.open();
|
||||
setupButton.click();
|
||||
controller.open();
|
||||
online = true;
|
||||
controller.render();
|
||||
findButton.click();
|
||||
process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"offline": {
|
||||
"findDisabled": True,
|
||||
"createDisabled": False,
|
||||
"status": "You are offline. Create a task now and it will stay in Drafts until you reconnect.",
|
||||
"createFocused": True,
|
||||
},
|
||||
"calls": ["create", "setup", "find"],
|
||||
"sheetOpen": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">' in html
|
||||
assert '<h2 id="mobile-first-task-title">Start your first task</h2>' in html
|
||||
assert 'id="mobile-first-task-find" type="button">Find & start</button>' in html
|
||||
assert 'id="mobile-first-task-create" type="button">Create & start</button>' in html
|
||||
assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready</button>' in html
|
||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-first-task-actions button { min-height:48px;' in html
|
||||
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v121" in service_worker
|
||||
assert "stackchain-dashboard-shell-v123" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -140,6 +140,35 @@ process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
|
||||
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
|
||||
|
||||
|
||||
def test_mobile_work_entry_guides_an_empty_first_workspace_before_find():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
let activationRequired = true;
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => false,
|
||||
isTodayResumable: () => false,
|
||||
getTodayCount: () => 0,
|
||||
getEligibleCount: () => 0,
|
||||
shouldActivate: () => activationRequired,
|
||||
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
|
||||
openActivation: () => calls.push('activate'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
activationRequired = false;
|
||||
modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["activate", "find"],
|
||||
"calls": ["activate", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
|
||||
@@ -410,7 +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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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
|
||||
+137
-3
@@ -22,6 +22,7 @@ const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callba
|
||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
|
||||
const deadlineSnooze = {hidden:true};
|
||||
const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }};
|
||||
const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback};
|
||||
@@ -31,10 +32,10 @@ const registration = {pushManager:{
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
});
|
||||
@@ -69,6 +70,27 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["text"] == "New update notifications enabled for this device."
|
||||
|
||||
|
||||
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
const degraded = {text:state.text,hidden:testControl.hidden};
|
||||
state.server = {delivery_state:'healthy',delivered:true};
|
||||
await state.testDelivery();
|
||||
process.stdout.write(JSON.stringify({degraded,text:state.text,hidden:testControl.hidden,disabled:testControl.disabled,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["degraded"] == {
|
||||
"text": "Update notifications need attention after 3 failed deliveries. Send a test notification.",
|
||||
"hidden": False,
|
||||
}
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/test", "POST"]
|
||||
assert result["text"] == "Test delivered. Update notifications are working on this device."
|
||||
assert result["hidden"] is False
|
||||
assert result["disabled"] is False
|
||||
|
||||
|
||||
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
|
||||
result = run_scenario("""
|
||||
state.current = existing;
|
||||
@@ -158,11 +180,121 @@ await feature.enableDeadline();
|
||||
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
|
||||
""")
|
||||
assert denied["checked"] is False
|
||||
assert denied["readiness"]["state"] == "incomplete"
|
||||
assert denied["readiness"]["state"] == "blocked"
|
||||
assert denied["readiness"]["actionLabel"] == "Check again"
|
||||
assert "blocked" in denied["readiness"]["detail"].lower()
|
||||
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
|
||||
|
||||
|
||||
def test_blocked_update_setup_can_recheck_without_reprompting_or_mutating():
|
||||
result = run_scenario("""
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
control.checked = true;
|
||||
await state.change();
|
||||
const blocked = feature.notificationReadiness();
|
||||
await feature.recoverPermission();
|
||||
process.stdout.write(JSON.stringify({blocked,after:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["blocked"] == {
|
||||
"state": "blocked",
|
||||
"detail": "Notifications are blocked. Allow them in browser settings, then check again.",
|
||||
"actionLabel": "Check again",
|
||||
}
|
||||
assert result["after"] == result["blocked"]
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"] == 0
|
||||
assert result["requests"] == [["api/v1/push-subscription", "GET", ""]]
|
||||
|
||||
|
||||
def test_previously_blocked_permission_starts_a_resumable_update_intent():
|
||||
result = run_scenario("""
|
||||
state.permission = 'denied';
|
||||
state.notification.permission = 'denied';
|
||||
await feature.init();
|
||||
await feature.recoverPermission('updates');
|
||||
state.notification.permission = 'granted';
|
||||
const readyToRecover = feature.notificationReadiness();
|
||||
await feature.recoverPermission('updates');
|
||||
process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["readyToRecover"]["actionLabel"] == "Check again"
|
||||
assert result["readiness"]["state"] == "complete"
|
||||
assert result["prompts"] == 0
|
||||
assert result["subscriptions"] == 1
|
||||
assert [request[0:2] for request in result["requests"]] == [
|
||||
["api/v1/push-subscription", "GET"],
|
||||
["api/v1/push-subscription", "PUT"],
|
||||
]
|
||||
|
||||
|
||||
def test_permission_recovery_resumes_pending_update_subscription_once():
|
||||
result = run_scenario("""
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
control.checked = true;
|
||||
await state.change();
|
||||
state.notification.permission = 'granted';
|
||||
const readyToRecover = feature.notificationReadiness();
|
||||
await feature.recoverPermission();
|
||||
await feature.recoverPermission();
|
||||
process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["readyToRecover"] == {
|
||||
"state": "blocked",
|
||||
"detail": "Notification permission changed. Check again to finish setup.",
|
||||
"actionLabel": "Check again",
|
||||
}
|
||||
assert result["readiness"]["state"] == "complete"
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"] == 1
|
||||
assert [request[0:2] for request in result["requests"]] == [
|
||||
["api/v1/push-subscription", "GET"],
|
||||
["api/v1/push-subscription", "PUT"],
|
||||
]
|
||||
|
||||
|
||||
def test_permission_recovery_resumes_pending_deadline_choices_once():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,reminder_days:2,public_key:'AQID'};
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
deadlineHour.value = '17';
|
||||
deadlineDays.value = '7';
|
||||
await feature.enableDeadline();
|
||||
const blocked = feature.deadlineReadiness();
|
||||
state.notification.permission = 'granted';
|
||||
const readyToRecover = feature.deadlineReadiness();
|
||||
await feature.recoverPermission();
|
||||
await feature.recoverPermission();
|
||||
process.stdout.write(JSON.stringify({blocked,readyToRecover,readiness:feature.deadlineReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["blocked"]["state"] == "blocked"
|
||||
assert result["blocked"]["actionLabel"] == "Check again"
|
||||
assert result["readyToRecover"] == {
|
||||
"state": "blocked",
|
||||
"detail": "Notification permission changed. Check again to finish setup.",
|
||||
"actionLabel": "Check again",
|
||||
}
|
||||
assert result["readiness"] == {
|
||||
"state": "complete",
|
||||
"detail": "Deadline reminders enabled for 17:00 local time, next 7 days.",
|
||||
}
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"] == 1
|
||||
assert [request[0:2] for request in result["requests"]] == [
|
||||
["api/v1/push-subscription", "GET"],
|
||||
["api/v1/push-subscription", "PUT"],
|
||||
["api/v1/push-subscription/deadlines", "PUT"],
|
||||
]
|
||||
assert json.loads(result["requests"][-1][2])["reminder_hour"] == 17
|
||||
assert json.loads(result["requests"][-1][2])["reminder_days"] == 7
|
||||
|
||||
|
||||
def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'};
|
||||
@@ -220,6 +352,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert 'id="push-test"' in html
|
||||
assert 'id="push-deadlines"' in html
|
||||
assert 'id="push-deadline-hour"' in html
|
||||
assert 'id="push-deadline-days"' in html
|
||||
@@ -227,6 +360,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
assert 'id="push-deadline-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
assert "testControl:qs('#push-test')" in dashboard
|
||||
assert "BASE + 'static/push-notifications.js'" in worker
|
||||
assert ".push-update-control" in css and "min-height:44px" in css
|
||||
assert "pywebpush==" in requirements
|
||||
|
||||
@@ -200,6 +200,46 @@ def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path
|
||||
assert second == []
|
||||
|
||||
|
||||
def test_delivery_health_is_bounded_per_channel_and_recovers_after_success(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
store.mark_delivery_failed("session-a", "unread", "timeout", now=100)
|
||||
store.mark_delivery_failed("session-a", "unread", "provider", now=110)
|
||||
store.mark_delivery_failed("session-a", "deadline", "provider", now=120)
|
||||
|
||||
assert store.delivery_health("session-a") == {
|
||||
"unread": {
|
||||
"state": "degraded",
|
||||
"consecutive_failures": 2,
|
||||
"last_attempted_at": 110,
|
||||
"last_succeeded_at": None,
|
||||
"reason": "provider",
|
||||
},
|
||||
"deadline": {
|
||||
"state": "degraded",
|
||||
"consecutive_failures": 1,
|
||||
"last_attempted_at": 120,
|
||||
"last_succeeded_at": None,
|
||||
"reason": "provider",
|
||||
},
|
||||
}
|
||||
|
||||
store.mark_delivery_succeeded("session-a", "unread", now=130)
|
||||
|
||||
assert store.delivery_health("session-a")["unread"] == {
|
||||
"state": "healthy",
|
||||
"consecutive_failures": 0,
|
||||
"last_attempted_at": 130,
|
||||
"last_succeeded_at": 130,
|
||||
"reason": None,
|
||||
}
|
||||
assert store.delivery_health("session-a")["deadline"]["state"] == "degraded"
|
||||
|
||||
|
||||
def test_subscription_store_reopens_a_delivered_thread_when_its_revision_changes(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
@@ -918,6 +958,42 @@ 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_unread_dispatch_records_degraded_health_then_recovers(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
revision = 0
|
||||
|
||||
async def unread():
|
||||
nonlocal revision
|
||||
revision += 1
|
||||
return {"items": [{"id": 42, "updated_at": f"r{revision}"}], "complete": True}
|
||||
|
||||
async def timeout_send(_subscription, _payload):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
for _ in range(3):
|
||||
assert await dispatch_unread_updates(store, config, unread, timeout_send) == 0
|
||||
|
||||
health = store.delivery_health("session-a")["unread"]
|
||||
assert health["state"] == "degraded"
|
||||
assert health["consecutive_failures"] == 3
|
||||
assert health["reason"] == "timeout"
|
||||
|
||||
async def succeed(_subscription, _payload):
|
||||
return None
|
||||
|
||||
assert await dispatch_unread_updates(store, config, unread, succeed) == 1
|
||||
recovered = store.delivery_health("session-a")["unread"]
|
||||
assert recovered["state"] == "healthy"
|
||||
assert recovered["consecutive_failures"] == 0
|
||||
assert recovered["last_succeeded_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
@@ -967,6 +1043,7 @@ async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_tha
|
||||
assert [(item.session_id, item.thread_ids) for item in remaining] == [
|
||||
("failing-device", (8,))
|
||||
]
|
||||
assert store.delivery_health("failing-device")["unread"]["reason"] == "timeout"
|
||||
|
||||
retried = []
|
||||
|
||||
@@ -1184,6 +1261,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
"delivery_health": {},
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
@@ -1191,6 +1269,79 @@ 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_authenticated_device_can_send_a_real_test_notification(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
}
|
||||
store.upsert("session-a", subscription)
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
monkeypatch.setattr(main, "_push_configuration", lambda: configuration)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(delivery, payload, config):
|
||||
sent.append((delivery, json.loads(payload), config))
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "send_web_push", send, raising=False)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
assert await main.test_push_notification(request) == {
|
||||
"delivery_state": "healthy",
|
||||
"delivered": True,
|
||||
}
|
||||
assert sent == [(
|
||||
subscription,
|
||||
{
|
||||
"title": "Stackchain notifications are working",
|
||||
"body": "This device can receive private work alerts.",
|
||||
"route": "#/device-setup",
|
||||
"tag": "stackchain-push-test",
|
||||
},
|
||||
configuration,
|
||||
)]
|
||||
assert store.delivery_health("session-a")["unread"]["state"] == "healthy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_test_notification_reports_provider_failure_as_degraded(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
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 "session-a"
|
||||
|
||||
async def fail(*_args):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "send_web_push", fail)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.test_push_notification(request)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.detail == "Test notification delivery failed"
|
||||
assert store.is_subscribed("session-a") is True
|
||||
assert store.delivery_health("session-a")["unread"]["reason"] == "timeout"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||
@@ -16,6 +20,105 @@ def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||
}
|
||||
|
||||
|
||||
def test_saved_searches_encrypt_private_views_and_survive_restart(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
key = b"s" * 32
|
||||
canary = "private-launch-query-canary"
|
||||
store = SavedSearchStore(database, encryption_key=key)
|
||||
|
||||
created = store.replace(
|
||||
"Timmy",
|
||||
0,
|
||||
[view(name="Private launch", query=canary, repository="private/launch")],
|
||||
)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert "Private launch" not in payload
|
||||
assert canary not in payload
|
||||
assert "private/launch" not in payload
|
||||
assert SavedSearchStore(database, encryption_key=key).get("timmy") == created
|
||||
|
||||
|
||||
def test_saved_searches_lazily_migrate_plaintext_without_changing_revision(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
key = b"s" * 32
|
||||
legacy_views = [view(name="Legacy private view", query="legacy-canary")]
|
||||
SavedSearchStore(database, encryption_key=key)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?)",
|
||||
("timmy", 7, json.dumps(legacy_views)),
|
||||
)
|
||||
|
||||
snapshot = SavedSearchStore(database, encryption_key=key).get("Timmy")
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
migrated = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()
|
||||
assert snapshot == {"revision": 7, "views": legacy_views}
|
||||
assert migrated[0] == 7
|
||||
assert migrated[1].startswith("v1:")
|
||||
assert "legacy-canary" not in migrated[1]
|
||||
|
||||
|
||||
def test_saved_searches_fail_closed_with_wrong_key(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
SavedSearchStore(database, encryption_key=b"s" * 32).replace(
|
||||
"timmy", 0, [view(query="wrong-key-canary")]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
SavedSearchStore(database, encryption_key=b"x" * 32).get("timmy")
|
||||
|
||||
|
||||
def test_saved_searches_reject_tampered_or_malformed_payloads(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
store = SavedSearchStore(database, encryption_key=b"s" * 32)
|
||||
store.replace("timmy", 0, [view(query="tamper-canary")])
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
|
||||
for invalid in (payload[:-1] + ("A" if payload[-1] != "A" else "B"), "not-json"):
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = 'timmy'",
|
||||
(invalid,),
|
||||
)
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
store.get("timmy")
|
||||
|
||||
|
||||
def test_saved_searches_reject_ciphertext_substituted_between_accounts(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
store = SavedSearchStore(database, encryption_key=b"s" * 32)
|
||||
store.replace("timmy", 0, [view(query="timmy-private")])
|
||||
store.replace("alexander", 0, [view(query="alexander-private")])
|
||||
with sqlite3.connect(database) as connection:
|
||||
timmy_payload = connection.execute(
|
||||
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = 'alexander'",
|
||||
(timmy_payload,),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
store.get("alexander")
|
||||
|
||||
|
||||
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
|
||||
|
||||
|
||||
@@ -61,6 +61,35 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||
assert b"stackchain_session" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tampered_security_activity_returns_no_partial_history(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
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",
|
||||
},
|
||||
)
|
||||
with sqlite3.connect(security_access / "security.sqlite3") as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT payload FROM security_events WHERE id = 1"
|
||||
).fetchone()[0]
|
||||
replacement = "A" if payload[-1] != "A" else "B"
|
||||
connection.execute(
|
||||
"UPDATE security_events SET payload = ? WHERE id = 1",
|
||||
(payload[:-1] + replacement,),
|
||||
)
|
||||
|
||||
activity = await client.get("/api/v1/security-events")
|
||||
|
||||
assert activity.status_code == 503
|
||||
assert activity.json() == {
|
||||
"detail": "Security activity is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_activity_limit_is_validated(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -304,3 +304,139 @@ const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecu
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {"backs": 1}
|
||||
|
||||
|
||||
def test_security_center_contains_focus_and_restores_prior_background_state_on_final_back():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=(id='', inert=false)=>({{
|
||||
id, inert, hidden:false, disabled:false, textContent:'', children:[], attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
focus(){{focused=this;}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}},
|
||||
scrollIntoView(){{}},
|
||||
}});
|
||||
let focused=null;
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
|
||||
ids['active-devices-sheet'].hidden=true;
|
||||
const header=element('header');
|
||||
const main=element('main', true);
|
||||
const dock=element('mobile-task-dock');
|
||||
const dynamicAction=element('revoke-lost-phone');
|
||||
const hiddenAction=element('hidden-action'); hiddenAction.hidden=true;
|
||||
const disabledAction=element('disabled-action'); disabledAction.disabled=true;
|
||||
ids['active-devices-sheet'].querySelectorAll=()=>[
|
||||
ids['close-active-devices'], dynamicAction, hiddenAction, disabledAction,
|
||||
];
|
||||
const documentListeners={{}};
|
||||
const root={{
|
||||
document:{{
|
||||
getElementById:id=>ids[id]||null, createElement:()=>element(),
|
||||
querySelector:selector=>({{'header':header,'main':main,'#mobile-task-dock':dock}})[selector]||null,
|
||||
addEventListener:(type,listener)=>documentListeners[type]=listener,
|
||||
}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
await controller.open();
|
||||
const opened={{inert:[header.inert,main.inert,dock.inert],focused:focused?.id}};
|
||||
let shiftPrevented=false;
|
||||
documentListeners.keydown({{key:'Tab',shiftKey:true,target:ids['close-active-devices'],preventDefault(){{shiftPrevented=true;}}}});
|
||||
const afterShift=focused?.id;
|
||||
let tabPrevented=false;
|
||||
documentListeners.keydown({{key:'Tab',shiftKey:false,target:dynamicAction,preventDefault(){{tabPrevented=true;}}}});
|
||||
const afterTab=focused?.id;
|
||||
controller.navigate('devices');
|
||||
header.inert=false; // An earlier popstate listener released its own overlay background.
|
||||
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
|
||||
header.inert=false; // That listener also reacts to the resulting hashchange.
|
||||
root.history.state={{stackchainSecuritySection:'activity'}};
|
||||
listeners.hashchange({{}});
|
||||
const sectionBack={{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert]}};
|
||||
root.history.state=null;
|
||||
listeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{
|
||||
opened,shiftPrevented,afterShift,tabPrevented,afterTab,sectionBack,
|
||||
closed:{{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert],focused:focused?.id}},
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"opened": {"inert": [True, True, True], "focused": "close-active-devices"},
|
||||
"shiftPrevented": True,
|
||||
"afterShift": "revoke-lost-phone",
|
||||
"tabPrevented": True,
|
||||
"afterTab": "close-active-devices",
|
||||
"sectionBack": {"hidden": False, "inert": [True, True, True]},
|
||||
"closed": {
|
||||
"hidden": True,
|
||||
"inert": [False, True, False],
|
||||
"focused": "active-devices",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_escape_closes_security_center_through_history_and_releases_background():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const windowListeners={{}};
|
||||
const documentListeners={{}};
|
||||
const element=(id='')=>({{
|
||||
id,inert:false,hidden:false,disabled:false,textContent:'',children:[],attributes:{{}},
|
||||
addEventListener(){{}},focus(){{focused=this.id;}},replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
|
||||
}});
|
||||
let focused='';
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
|
||||
ids['active-devices-sheet'].hidden=true;
|
||||
ids['active-devices-sheet'].querySelectorAll=()=>[ids['close-active-devices']];
|
||||
const header=element('header');
|
||||
let backs=0;
|
||||
const root={{
|
||||
document:{{
|
||||
getElementById:id=>ids[id]||null,createElement:()=>element(),
|
||||
querySelector:selector=>selector==='header'?header:null,
|
||||
addEventListener:(type,listener)=>documentListeners[type]=listener,
|
||||
}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>windowListeners[type]=listener,
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
let prevented=false;
|
||||
documentListeners.keydown({{key:'Escape',preventDefault(){{prevented=true;}}}});
|
||||
const requested={{backs,prevented,hidden:ids['active-devices-sheet'].hidden,inert:header.inert}};
|
||||
windowListeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{requested,closed:{{hidden:ids['active-devices-sheet'].hidden,inert:header.inert,focused}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"requested": {"backs": 1, "prevented": True, "hidden": False, "inert": True},
|
||||
"closed": {"hidden": True, "inert": False, "focused": "active-devices"},
|
||||
}
|
||||
@@ -1,6 +1,137 @@
|
||||
import sqlite3
|
||||
|
||||
from src.security_event_store import SecurityEventStore
|
||||
import pytest
|
||||
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"e" * 32
|
||||
|
||||
|
||||
def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
|
||||
store = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3", clock=lambda: 1_000, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
store.record("issue_closed", method="passkey", target="private/repo#42")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
assert columns == {
|
||||
"id",
|
||||
"payload",
|
||||
"created_at",
|
||||
"status",
|
||||
"operation_id",
|
||||
}
|
||||
|
||||
|
||||
def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "security.sqlite3"
|
||||
canaries = {
|
||||
"kind": "private_issue_closed_canary",
|
||||
"method": "private_passkey_canary",
|
||||
"device_label": "Private Operator Phone Canary",
|
||||
"target": "private-org/roadmap#4242",
|
||||
}
|
||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||
|
||||
store.record(**canaries)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT payload FROM security_events"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
database_bytes = path.read_bytes()
|
||||
assert all(value.encode() not in database_bytes for value in canaries.values())
|
||||
reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY)
|
||||
event = reopened.list(limit=10).events[0]
|
||||
assert (event.kind, event.method, event.device_label, event.target) == tuple(
|
||||
canaries.values()
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_path):
|
||||
path = tmp_path / "security.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT,
|
||||
device_label TEXT,
|
||||
target TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
);
|
||||
INSERT INTO security_events
|
||||
(id, kind, method, device_label, target, created_at, status, operation_id)
|
||||
VALUES
|
||||
(7, 'legacy_sign_in_canary', 'legacy_token_canary',
|
||||
'Legacy Phone Canary', 'dashboard', 900, 'completed', NULL),
|
||||
(9, 'legacy_issue_closed_canary', 'passkey',
|
||||
'Legacy Tablet Canary', 'private/repo#9', 901, 'pending', 'operation-9');
|
||||
"""
|
||||
)
|
||||
|
||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||
page = store.list(limit=1)
|
||||
older = store.list(limit=10, cursor=page.next_cursor)
|
||||
|
||||
assert [(event.id, event.kind, event.created_at, event.status) for event in page.events] == [
|
||||
(9, "legacy_issue_closed_canary", 901, "pending")
|
||||
]
|
||||
assert [(event.id, event.kind, event.created_at, event.status) for event in older.events] == [
|
||||
(7, "legacy_sign_in_canary", 900, "completed")
|
||||
]
|
||||
store.finalize("operation-9")
|
||||
assert store.list(limit=1).events[0].status == "completed"
|
||||
with sqlite3.connect(path) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, payload FROM security_events ORDER BY id"
|
||||
).fetchall()
|
||||
assert [row[0] for row in rows] == [7, 9]
|
||||
assert all(row[1].startswith("v1:") for row in rows)
|
||||
database_bytes = path.read_bytes()
|
||||
assert b"legacy_sign_in_canary" not in database_bytes
|
||||
assert b"Legacy Tablet Canary" not in database_bytes
|
||||
|
||||
|
||||
def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
|
||||
path = tmp_path / "security.sqlite3"
|
||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||
store.record("issue_closed", target="private/repo#42")
|
||||
|
||||
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
||||
SecurityEventStore(
|
||||
path, clock=lambda: 1_001, encryption_key=b"x" * 32
|
||||
).list(limit=10)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT payload FROM security_events WHERE id = 1"
|
||||
).fetchone()[0]
|
||||
replacement = "A" if payload[-1] != "A" else "B"
|
||||
connection.execute(
|
||||
"UPDATE security_events SET payload = ? WHERE id = 1",
|
||||
(payload[:-1] + replacement,),
|
||||
)
|
||||
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
||||
store.list(limit=10)
|
||||
|
||||
|
||||
def test_missing_security_activity_encryption_key_fails_with_store_error(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", raising=False)
|
||||
|
||||
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
||||
SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
|
||||
|
||||
def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path):
|
||||
@@ -31,8 +162,7 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
|
||||
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
assert columns == {
|
||||
"id", "kind", "method", "device_label", "target", "created_at",
|
||||
"status", "operation_id",
|
||||
"id", "payload", "created_at", "status", "operation_id",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -215,7 +215,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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -249,21 +249,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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -1137,7 +1137,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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/sign-out-review.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/workspace-bootstrap.js",
|
||||
"/dashboard/static/conversation-action-hydrator.js",
|
||||
@@ -1256,6 +1257,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-today-command-bar.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-first-task.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "sign-out-review.js"
|
||||
|
||||
|
||||
def run_scenario(scenario: str, *, inventory: str = "{recordCount:3, unavailable:false}") -> dict:
|
||||
harness = r"""
|
||||
const createSignOutReview = require(__MODULE__);
|
||||
class Target {
|
||||
constructor() { this.listeners={}; this.hidden=false; this.textContent=''; this.disabled=false; this.dataset={}; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name, event={}) { for (const callback of this.listeners[name] || []) await callback({preventDefault() { state.prevented = true; }, shiftKey:false, currentTarget:this, ...event}); }
|
||||
focus() { state.focused=this; }
|
||||
contains(target) { return target === this; }
|
||||
querySelectorAll() { return [cancelButton, confirmButton]; }
|
||||
}
|
||||
const state={signedOut:[], focused:null, historyPushes:0, historyBacks:0};
|
||||
const launcher=new Target();
|
||||
const allLauncher=new Target();
|
||||
const sheet=new Target(); sheet.hidden=true;
|
||||
const heading=new Target();
|
||||
const summary=new Target();
|
||||
const warning=new Target();
|
||||
const cancelButton=new Target();
|
||||
const confirmButton=new Target();
|
||||
const escapeTarget=new Target();
|
||||
const historyTarget=new Target();
|
||||
const background=new Target(); background.inert=false;
|
||||
const storage={
|
||||
values:new Map([['stackchain.private','secret'],['stackchain.draft','draft'],['gitea.preference','keep']]),
|
||||
get length() { return this.values.size; },
|
||||
key(index) { return Array.from(this.values.keys())[index] || null; },
|
||||
};
|
||||
const history={
|
||||
pushState() { state.historyPushes += 1; },
|
||||
back() { state.historyBacks += 1; historyTarget.dispatch('popstate'); },
|
||||
};
|
||||
const review=createSignOutReview({
|
||||
launcher, allLauncher, sheet, heading, summary, warning, cancelButton, confirmButton,
|
||||
escapeTarget, historyTarget, history, localStorage:storage, sessionStorage:storage,
|
||||
backgroundTargets:[background], getActiveElement:() => state.focused,
|
||||
privateDatabases:['private-work'], inspectPrivateDatabases:async () => (__INVENTORY__),
|
||||
onConfirm:async mode => { if (state.failConfirm) throw new Error('blocked purge'); state.signedOut.push(mode); },
|
||||
});
|
||||
(async () => { review.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__INVENTORY__", inventory).replace("__SCENARIO__", scenario)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_private_work_is_counted_before_sign_out_and_requires_sheet_confirmation():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
const before=[...state.signedOut];
|
||||
await confirmButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
before, after:state.signedOut, hidden:sheet.hidden, heading:heading.textContent,
|
||||
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"before": [],
|
||||
"after": ["current"],
|
||||
"hidden": True,
|
||||
"heading": "Review sign out",
|
||||
"summary": "This device has 2 private browser items and 3 private work records.",
|
||||
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
|
||||
"action": "Sign out and erase private work",
|
||||
}
|
||||
|
||||
|
||||
def test_keyboard_focus_is_contained_inside_open_review():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
state.focused=confirmButton;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab'});
|
||||
const wrappedForward=state.focused === cancelButton;
|
||||
state.focused=cancelButton;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true});
|
||||
process.stdout.write(JSON.stringify({wrappedForward, wrappedBackward:state.focused === confirmButton, prevented:state.prevented || false}));
|
||||
""")
|
||||
|
||||
assert result == {"wrappedForward": True, "wrappedBackward": True, "prevented": True}
|
||||
|
||||
|
||||
def test_cancel_removes_review_history_and_preserves_session_and_private_work():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
await cancelButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
signedOut:state.signedOut, hidden:sheet.hidden, historyPushes:state.historyPushes,
|
||||
historyBacks:state.historyBacks, launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"signedOut": [], "hidden": True, "historyPushes": 1,
|
||||
"historyBacks": 1, "launcherFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_open_review_makes_background_inert_until_cancelled():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
const inertWhileOpen=background.inert;
|
||||
await cancelButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({inertWhileOpen, inertAfterCancel:background.inert}));
|
||||
""")
|
||||
|
||||
assert result == {"inertWhileOpen": True, "inertAfterCancel": False}
|
||||
|
||||
|
||||
def test_sign_out_all_review_combines_global_session_and_local_work_warning():
|
||||
result = run_scenario("""
|
||||
await allLauncher.dispatch('click');
|
||||
const before=[...state.signedOut];
|
||||
await confirmButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
before, after:state.signedOut, heading:heading.textContent,
|
||||
warning:warning.textContent, action:confirmButton.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"before": [], "after": ["all"],
|
||||
"heading": "Review sign out on every device",
|
||||
"warning": "Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.",
|
||||
"action": "Sign out and erase private work",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_inventory_never_claims_the_device_is_empty():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
signedOut:state.signedOut, hidden:sheet.hidden,
|
||||
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
|
||||
}));
|
||||
""", inventory="Promise.reject(new Error('blocked'))")
|
||||
|
||||
assert result == {
|
||||
"signedOut": [], "hidden": False,
|
||||
"summary": "This device has 2 private browser items; private work status is unknown.",
|
||||
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
|
||||
"action": "Sign out and erase private work",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_purge_keeps_review_open_with_recovery_guidance():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
state.failConfirm=true;
|
||||
try { await confirmButton.dispatch('click'); } catch (_error) {}
|
||||
process.stdout.write(JSON.stringify({
|
||||
signedOut:state.signedOut, hidden:sheet.hidden, disabled:confirmButton.disabled,
|
||||
warning:warning.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"signedOut": [], "hidden": False, "disabled": False,
|
||||
"warning": "Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.",
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_renders_and_wires_phone_safe_sign_out_review():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
session = (root / "frontend" / "session.js").read_text()
|
||||
review = MODULE.read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="sign-out-review-sheet"' in html
|
||||
assert 'role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading"' in html
|
||||
assert 'id="sign-out-review-summary"' in html
|
||||
assert 'id="sign-out-review-warning"' in html
|
||||
assert 'id="cancel-sign-out"' in html
|
||||
assert 'id="confirm-sign-out"' in html
|
||||
assert html.index('static/private-data-inventory.js') < html.index('static/session.js')
|
||||
assert html.index('static/sign-out-review.js') < html.index('static/session.js')
|
||||
assert "root.createSignOutReview" in session
|
||||
assert "inspectPrivateDatabases: root.inspectStackchainPrivateDatabases" in review
|
||||
assert "stackchain-feature-sign-out" in session
|
||||
assert "max-height:100dvh" in css
|
||||
assert ".sign-out-review-actions button { min-height:44px" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/sign-out-review.js'" in worker
|
||||
@@ -65,6 +65,34 @@ process.stdout.write(JSON.stringify(timer.snapshot()));
|
||||
}
|
||||
|
||||
|
||||
def test_reactivating_the_active_item_during_reload_preserves_its_timed_break():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now = 100000;
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
|
||||
timer.activate('issue:r:42:');
|
||||
timer.startBreak(5);
|
||||
const reopened = timer.activate('issue:r:42:');
|
||||
process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"reopened": True,
|
||||
"snapshot": {
|
||||
"identity": "issue:r:42:",
|
||||
"elapsed_ms": 0,
|
||||
"running": False,
|
||||
"break_deadline_at": 400_000,
|
||||
},
|
||||
"pending": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 400_000,
|
||||
"expired": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
|
||||
@@ -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-v121';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v123';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,115 @@ import pytest
|
||||
import httpx
|
||||
|
||||
from src import main
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
|
||||
|
||||
|
||||
def test_task_bearing_today_journey_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"t" * 32
|
||||
store = TodayStore(path, encryption_key=key, clock=lambda: 1234.5)
|
||||
issue = "issue:private-roadmap/launch:4242:"
|
||||
device = "private-phone-canary"
|
||||
recap_session = "private-recap-canary"
|
||||
|
||||
plan = store.apply_batch("Timmy", [
|
||||
{"operation_id": "add", "action": "add", "item_id": issue},
|
||||
{"operation_id": "configure", "action": "configure", "item_id": "plan",
|
||||
"capacity_minutes": 180, "estimates": {issue: 75}},
|
||||
])
|
||||
session = store.update_session(
|
||||
"timmy", base_revision=0, device_id=device, identity=issue,
|
||||
elapsed_ms=90_000, running=True,
|
||||
)
|
||||
recap = store.save_recap("timmy", recap_session, [
|
||||
{"identity": issue, "estimate_minutes": 75, "actual_minutes": 91}
|
||||
])
|
||||
assert store.begin_time_log("timmy", recap_session, issue, 91) == "claimed"
|
||||
store.finish_time_log("timmy", recap_session, issue, succeeded=True)
|
||||
|
||||
retained = b"".join(
|
||||
candidate.read_bytes()
|
||||
for candidate in (path, path.with_name(path.name + "-wal"))
|
||||
if candidate.exists()
|
||||
)
|
||||
for canary in (issue, device, recap_session):
|
||||
assert canary.encode() not in retained
|
||||
assert retained.count(b"v1:") >= 4
|
||||
|
||||
reopened = TodayStore(path, encryption_key=key)
|
||||
assert reopened.get("timmy") == {
|
||||
key: value for key, value in plan.items()
|
||||
if key not in {"accepted_operation_ids", "duplicate_operation_ids", "rejected_operations"}
|
||||
}
|
||||
assert reopened.get_session("timmy") == session
|
||||
assert reopened.list_recaps("timmy") == [recap]
|
||||
assert reopened.begin_time_log("timmy", recap_session, issue, 91) == "logged"
|
||||
|
||||
|
||||
def test_legacy_today_rows_migrate_on_use_without_changing_behavior(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"m" * 32
|
||||
store = TodayStore(path, encryption_key=key)
|
||||
issue = "issue:legacy-private/project:7:"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
("timmy", 4, f'["{issue}"]', 120, f'{{"{issue}":45}}', "2026-08-19", "UTC"),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("timmy", 3, "legacy-phone", issue, 5000, 1, None, 99.0),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps VALUES (?, ?, ?, ?)",
|
||||
("timmy", "legacy-session", 88.0,
|
||||
f'[{ {"identity": issue, "estimate_minutes": 45, "actual_minutes": 50} }]'.replace("'", '"')),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs VALUES (?, ?, ?, ?, ?)",
|
||||
("timmy", "legacy-session", issue, 50, "pending"),
|
||||
)
|
||||
|
||||
assert store.get("timmy")["revision"] == 4
|
||||
assert store.get_session("timmy")["revision"] == 3
|
||||
assert store.list_recaps("timmy")[0]["items"][0]["identity"] == issue
|
||||
assert store.begin_time_log("timmy", "legacy-session", issue, 50) == "pending"
|
||||
|
||||
retained = path.read_bytes()
|
||||
for canary in (issue, "legacy-phone", "legacy-session"):
|
||||
assert canary.encode() not in retained
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tampered_or_account_substituted_today_state_fails_closed_with_retry(monkeypatch, tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, encryption_key=b"a" * 32)
|
||||
store.apply("timmy", "seed", "add", "issue:private/repo:9:")
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT ids FROM today_plans WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, estimates) VALUES ('alexander', 1, ?, '{}')",
|
||||
(payload,),
|
||||
)
|
||||
|
||||
with pytest.raises(PrivateStateEncryptionError):
|
||||
store.get("alexander")
|
||||
|
||||
async def user():
|
||||
return {"login": "alexander"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.get_today_plan()
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.detail == "Today synchronization is unavailable"
|
||||
assert raised.value.headers == {"Retry-After": "1"}
|
||||
|
||||
|
||||
def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
|
||||
@@ -116,6 +116,109 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["keptAfterCopy"] is False
|
||||
|
||||
|
||||
def test_summary_validates_an_exact_gitea_thread_before_durable_posting():
|
||||
script = f"""
|
||||
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const resolved=[];
|
||||
const admitted=[];
|
||||
const summary=createSummary({{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'summary-op-1',
|
||||
resolveTarget:async target=>{{resolved.push(target);return{{...target,kind:'issue',title:'Daily status',state:'open'}};}},
|
||||
enqueueDurably:async message=>{{admitted.push(message);return{{item:message,durability:'background'}};}},
|
||||
}});
|
||||
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
||||
const parsed=summary.setDestination(' StackChain/Status #42 ');
|
||||
const target=await summary.validateDestination();
|
||||
const result=await summary.postSummary();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
parsed,target,result,resolved,admitted,snapshot:summary.snapshot(),
|
||||
kept:values.has('stackchain.today-summary-draft.v1.timmy'),
|
||||
}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["parsed"] == {
|
||||
"repository": "stackchain/status", "number": 42, "label": "stackchain/status#42"
|
||||
}
|
||||
assert output["resolved"] == [{"repository": "stackchain/status", "number": 42}]
|
||||
assert output["target"] == {
|
||||
"repository": "stackchain/status", "number": 42, "kind": "issue",
|
||||
"title": "Daily status", "state": "open",
|
||||
}
|
||||
assert output["admitted"] == [{
|
||||
"kind": "search-reply", "repository": "stackchain/status", "number": 42,
|
||||
"targetKind": "issue", "body": "Today\n- Ship mobile capture",
|
||||
"operationId": "summary-op-1",
|
||||
}]
|
||||
assert output["result"]["status"] == "queued"
|
||||
assert output["result"]["durability"] == "background"
|
||||
assert output["snapshot"] is None
|
||||
assert output["kept"] is False
|
||||
|
||||
|
||||
def test_summary_keeps_reviewed_destination_and_operation_when_admission_fails():
|
||||
script = f"""
|
||||
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const options={{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'stable-op',
|
||||
resolveTarget:async target=>({{...target,kind:'pull',title:'Release candidate',state:'open'}}),
|
||||
enqueueDurably:async()=>{{throw new Error('storage unavailable');}},
|
||||
}};
|
||||
const summary=createSummary(options);
|
||||
summary.begin([{{identity:'pull:acme/mobile:9:',title:'Review release',actual_minutes:18}}], []);
|
||||
summary.setDestination('acme/mobile#9');
|
||||
await summary.validateDestination();
|
||||
let error='';
|
||||
try {{ await summary.postSummary(); }} catch (caught) {{ error=caught.message; }}
|
||||
const restored=createSummary(options);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error,restored:restored.restore(),snapshot:restored.snapshot(),text:restored.text(),
|
||||
}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["error"] == "storage unavailable"
|
||||
assert output["restored"] is True
|
||||
assert output["snapshot"]["operation_id"] == "stable-op"
|
||||
assert output["snapshot"]["target"]["kind"] == "pull"
|
||||
assert output["text"] == "Today\n- Review release"
|
||||
|
||||
|
||||
def test_summary_coalesces_concurrent_post_taps_into_one_durable_admission():
|
||||
script = f"""
|
||||
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||
let release;
|
||||
const gate=new Promise(resolve=>release=resolve);
|
||||
const admitted=[];
|
||||
const summary=createSummary({{
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'one-operation',
|
||||
resolveTarget:async target=>({{...target,kind:'issue',title:'Status',state:'open'}}),
|
||||
enqueueDurably:async message=>{{admitted.push(message);await gate;return{{item:message,durability:'background'}};}},
|
||||
}});
|
||||
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
||||
summary.setDestination('acme/mobile#41');
|
||||
await summary.validateDestination();
|
||||
const first=summary.postSummary();
|
||||
const second=summary.postSummary();
|
||||
release();
|
||||
const results=await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify({{admitted,results}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert len(output["admitted"]) == 1
|
||||
assert output["results"][0] == output["results"][1]
|
||||
|
||||
|
||||
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
|
||||
from src import main
|
||||
|
||||
@@ -130,13 +233,21 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
|
||||
assert 'id="today-summary-tomorrow"' in html
|
||||
assert 'id="today-summary-include-actuals"' in html
|
||||
assert 'id="share-today-summary"' in html
|
||||
assert 'id="today-summary-destination"' in html
|
||||
assert 'id="validate-today-summary-destination"' in html
|
||||
assert 'id="post-today-summary"' in html
|
||||
assert 'id="today-summary-target"' in html
|
||||
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
|
||||
assert "setupTodaySummary({" in dashboard
|
||||
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in dashboard
|
||||
assert "fetchJson:api" in dashboard
|
||||
assert "/preview?kind=issue" in (ROOT / "frontend" / "today-summary.js").read_text()
|
||||
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
|
||||
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
|
||||
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
|
||||
assert ".today-summary-actions button { min-height:44px;" in css
|
||||
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
|
||||
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
|
||||
assert "tests/e2e/test_mobile_today_summary_release.py" in (
|
||||
ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||
).read_text()
|
||||
|
||||
@@ -191,7 +191,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-v121" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -391,6 +391,35 @@ process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, re
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_detour_excludes_troubleshooting_time_and_resumes_once():
|
||||
script = SOURCE.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now = 1000;
|
||||
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
||||
timer.activate('issue:r:42:');
|
||||
now = 6000;
|
||||
const interruption = timer.beginDetour('live-data-status');
|
||||
now = 26000;
|
||||
const paused = timer.snapshot();
|
||||
const returned = timer.returnFromDetour('live-data-status');
|
||||
now = 28000;
|
||||
const resumed = timer.snapshot();
|
||||
const repeated = timer.returnFromDetour('live-data-status');
|
||||
process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, repeated}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "live-data-status"},
|
||||
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
||||
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "live-data-status"},
|
||||
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
||||
"repeated": None,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_detour_view_names_work_and_returns_to_today():
|
||||
script = SOURCE.read_text() + r"""
|
||||
const values = new Map();
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
|
||||
|
||||
def run_bootstrap(scenario: str) -> dict:
|
||||
@@ -35,33 +37,71 @@ console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails():
|
||||
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''};
|
||||
const document={
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{throw new Error('network failed');}});
|
||||
let message='';
|
||||
try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; }
|
||||
console.log(JSON.stringify({message,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"message": "network failed",
|
||||
"status": "Workspace could not load. Check your connection, then reload to retry.",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0;
|
||||
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
if (selector === '#retry-workspace') return retry;
|
||||
return null;
|
||||
}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
|
||||
const schedule=callback=>{callback();};
|
||||
await loadWorkspace({document,createLoader,schedule});
|
||||
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {"attempts": 2, "status": "", "retryHidden": True}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
|
||||
const retry={hidden:true,disabled:false,addEventListener(name,callback){listeners[name]=callback;}};
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
if (selector === '#retry-workspace') return retry;
|
||||
return null;
|
||||
}};
|
||||
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
||||
const first=listeners.click(); const second=listeners.click();
|
||||
await Promise.all([first,second,loading]);
|
||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"reloads": 0,
|
||||
"offered": {
|
||||
"hidden": False,
|
||||
"disabled": False,
|
||||
"status": "Workspace unavailable. Reconnect or retry.",
|
||||
},
|
||||
"status": "",
|
||||
"retryHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_retry_control_is_accessible_and_touch_safe():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>' in index
|
||||
assert ".retry-work-route { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_workspace_bootstrap_reconnect_retries_in_place_without_reloading():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
|
||||
const retry={hidden:true,disabled:false,addEventListener(){},removeEventListener(){}};
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
if (selector === '#retry-workspace') return retry;
|
||||
return null;
|
||||
}};
|
||||
const window={
|
||||
@@ -69,39 +109,22 @@ const window={
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{throw new Error('offline');}});
|
||||
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const waiting=Boolean(listeners.online);
|
||||
if (listeners.online) listeners.online();
|
||||
console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
|
||||
await loading;
|
||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"waiting": True,
|
||||
"reloads": 1,
|
||||
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
|
||||
"reloads": 0,
|
||||
"status": "",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0;
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}};
|
||||
const window={
|
||||
location:{reload(){reloads++;}},
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}});
|
||||
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
||||
console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)}));
|
||||
""")
|
||||
assert result == {"reloads": 1, "waiting": False}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={};
|
||||
|
||||
Reference in new issue
Block a user