Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18aa5ed63 | ||
|
|
dbc305108a | ||
|
|
3b9f184a47 | ||
|
|
bf716cab6e | ||
|
|
dbabd8640b | ||
|
|
890fd57531 | ||
|
|
b0b051184b | ||
|
|
2432d4fc03 | ||
|
|
c97383fccb | ||
|
|
89b1ad1f08 | ||
|
|
0328e197ac | ||
|
|
7a09452bef | ||
|
|
4b4637fadb | ||
|
|
977493907e |
No files matched your search
@@ -121,6 +121,14 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
|
||||
remains immediate when offline and is marked **Sync pending** until connectivity returns. The complete
|
||||
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
|
||||
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
|
||||
losing either order. Resetting publishes the canonical default order. Delivery, Human Gates, and active
|
||||
Prepare Today precedence are not customizable. Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the
|
||||
default `.stackchain-state/queue-priority.sqlite3` path.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
|
||||
@@ -1515,6 +1515,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; }
|
||||
.mobile-queue-next p, .mobile-queue-group h3 { margin:0; }
|
||||
.mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; }
|
||||
.mobile-queue-priority { margin-top:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-queue-priority > summary { min-height:44px; display:flex; align-items:center; padding:0 12px; cursor:pointer; font-weight:700; }
|
||||
.mobile-queue-priority > p { margin:0; padding:0 12px 10px; }
|
||||
.mobile-queue-priority-list { display:grid; gap:6px; padding:0 8px; }
|
||||
.mobile-queue-priority-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; min-width:0; padding:6px 4px; border-top:1px solid #233f61; }
|
||||
.mobile-queue-priority-row > span:first-child { min-width:0; overflow-wrap:anywhere; font-weight:700; }
|
||||
.mobile-queue-priority-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; }
|
||||
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
|
||||
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
|
||||
.mobile-queue-priority-footer button { min-height:44px; }
|
||||
.mobile-queue-priority-conflict { margin:0 8px 10px; padding:10px; border:1px solid #f59e0b; border-radius:10px; background:#3b2808; }
|
||||
.mobile-queue-priority-conflict p { margin:0 0 8px; }
|
||||
.mobile-queue-priority-conflict button { min-height:44px; margin:4px 4px 0 0; }
|
||||
.mobile-queue-group { margin-top:16px; }
|
||||
.mobile-queue-group h3 { font-size:1rem; }
|
||||
.mobile-queue-all { margin-top:16px; }
|
||||
|
||||
+38
-1
@@ -112,7 +112,9 @@
|
||||
}
|
||||
let queueCounts = {};
|
||||
let preparationItems = {};
|
||||
let offlineWorkMode = false;
|
||||
let renderMobileQueuePresentation = () => {};
|
||||
let mobileQueuePriority = null;
|
||||
const followingQueue = attachFollowing(item => {
|
||||
searchPreviewReturnKind = 'following';
|
||||
return searchPreview.open(item);
|
||||
@@ -123,12 +125,14 @@
|
||||
queueCounts.followingUnavailable = false;
|
||||
preparationItems.following = items.filter(item => item.has_unseen_change === true);
|
||||
renderMobileQueuePresentation();
|
||||
mobileTaskDock.updateQueues(queueCounts);
|
||||
mobileStartDay.render();
|
||||
},
|
||||
onStatus:status => {
|
||||
if (status === 'loading') return;
|
||||
queueCounts.followingUnavailable = status === 'error';
|
||||
renderMobileQueuePresentation();
|
||||
mobileTaskDock.updateQueues(queueCounts);
|
||||
mobileStartDay.render();
|
||||
},
|
||||
onReviewComplete:() => mobileStartDay.completePhase('following'),
|
||||
@@ -179,6 +183,8 @@
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: announceWork,
|
||||
getCounts: () => queueCounts,
|
||||
isOnline: () => !offlineWorkMode,
|
||||
getRoutineOrder: () => mobileQueuePriority?.getOrder(),
|
||||
getPreparation: () => {
|
||||
const briefing = mobileStartDay.briefing();
|
||||
return {...briefing, active:mobileStartDay.state().active};
|
||||
@@ -352,7 +358,6 @@
|
||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||
let liveMode = true;
|
||||
let offlineWorkMode = false;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
|
||||
@@ -425,6 +430,28 @@
|
||||
let planningOwnerLogin = '';
|
||||
let planningOwnerAccountKey = '';
|
||||
let activeFlushLogin = '';
|
||||
mobileQueuePriority = createMobileQueuePriority({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
fetchJson: fetchReviewJson,
|
||||
document,
|
||||
list: qs('#mobile-queue-priority-list'),
|
||||
resetButton: qs('#reset-mobile-queue-priority'),
|
||||
status: qs('#mobile-queue-priority-status'),
|
||||
conflict: qs('#mobile-queue-priority-conflict'),
|
||||
keepLocalButton: qs('#keep-local-mobile-queue-priority'),
|
||||
useRemoteButton: qs('#use-remote-mobile-queue-priority'),
|
||||
labels: {
|
||||
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
|
||||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
},
|
||||
onChange: () => {
|
||||
renderMobileQueuePresentation();
|
||||
void mobileQueuePriority.sync();
|
||||
},
|
||||
});
|
||||
mobileQueuePriority.start();
|
||||
window.addEventListener('online', () => { void mobileQueuePriority.sync(); });
|
||||
let rR = null;
|
||||
function rRC() {
|
||||
if (rR) return rR;
|
||||
@@ -657,6 +684,7 @@
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
|
||||
error.code = payload.detail?.code;
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
@@ -3547,9 +3575,12 @@
|
||||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||||
counts.gate = queueCounts.gate;
|
||||
counts.gateUnavailable = queueCounts.gateUnavailable;
|
||||
counts.following = queueCounts.following;
|
||||
counts.followingUnavailable = queueCounts.followingUnavailable;
|
||||
preparationItems = {
|
||||
delivery:draftInbox.partition(lastDrafts).deliveries,
|
||||
gate:preparationItems.gate || [],
|
||||
following:preparationItems.following || [],
|
||||
agenda:agendaMyWork(activeMyWork),
|
||||
attention:activeMyWork.filter(item => item.needs_attention),
|
||||
update:activeMyWork.filter(item => item.has_update),
|
||||
@@ -5614,6 +5645,9 @@
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
mobileQueuePriority.render();
|
||||
renderMobileQueuePresentation();
|
||||
void mobileQueuePriority.load();
|
||||
void refreshPhotoDraftInbox();
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
@@ -7932,6 +7966,7 @@
|
||||
}
|
||||
function setOfflineWorkMode(value) {
|
||||
offlineWorkMode = value;
|
||||
renderMobileQueuePresentation();
|
||||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||||
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
|
||||
if (value) {
|
||||
@@ -7944,6 +7979,8 @@
|
||||
if (!saved) return false;
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
mobileQueuePriority.render();
|
||||
renderMobileQueuePresentation();
|
||||
restoreReleaseReceipt();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
|
||||
|
||||
@@ -2183,6 +2183,20 @@
|
||||
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||
</section>
|
||||
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
||||
<summary>Customize routine order</summary>
|
||||
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
|
||||
<div id="mobile-queue-priority-list" class="mobile-queue-priority-list"></div>
|
||||
<div class="mobile-queue-priority-footer">
|
||||
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
|
||||
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
|
||||
</div>
|
||||
<div id="mobile-queue-priority-conflict" class="mobile-queue-priority-conflict" hidden>
|
||||
<p class="small">This routine was changed on another device. Choose which complete order to keep.</p>
|
||||
<button id="keep-local-mobile-queue-priority" type="button">Keep this device</button>
|
||||
<button id="use-remote-mobile-queue-priority" type="button">Use other device</button>
|
||||
</div>
|
||||
</details>
|
||||
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
||||
<h3 id="mobile-queue-active-heading">Active now</h3>
|
||||
<div class="mobile-queue-list" id="mobile-queue-active-list"></div>
|
||||
@@ -2402,6 +2416,7 @@
|
||||
<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-priority.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
|
||||
@@ -8,26 +8,40 @@
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['gate', 'Review Human Gates'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
const labels = {
|
||||
delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
|
||||
today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
|
||||
following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
|
||||
later: 'Start Later', draft: 'Open Drafts',
|
||||
};
|
||||
const criticalQueueNames = ['delivery', 'gate'];
|
||||
const defaultRoutineOrder = [
|
||||
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
||||
];
|
||||
const activeQueueNames = continuation.map(([name]) => name).concat(['following', 'authored']);
|
||||
const onlineOnlyQueues = new Set(['delivery', 'gate']);
|
||||
const allQueues = [
|
||||
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
|
||||
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
|
||||
];
|
||||
|
||||
function routineOrder() {
|
||||
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
|
||||
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
|
||||
new Set(proposed).size !== defaultRoutineOrder.length ||
|
||||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
|
||||
return proposed.slice();
|
||||
}
|
||||
|
||||
function activeQueueNames() {
|
||||
return criticalQueueNames.concat(routineOrder());
|
||||
}
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
const online = options.isOnline ? options.isOnline() : true;
|
||||
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
|
||||
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
|
||||
);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
@@ -45,10 +59,11 @@
|
||||
|
||||
function presentation() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const active = activeQueueNames
|
||||
const names = activeQueueNames();
|
||||
const active = names
|
||||
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
|
||||
.filter(item => item.count > 0);
|
||||
activeQueueNames.forEach(name => {
|
||||
names.forEach(name => {
|
||||
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
|
||||
active.push({name, unavailable: true});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueuePriority = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
|
||||
const DEFAULT_ORDER = [
|
||||
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
||||
];
|
||||
const storage = options.storage;
|
||||
const getLogin = options.getLogin || (() => '');
|
||||
const fetchJson = options.fetchJson;
|
||||
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
||||
const labels = options.labels || {};
|
||||
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
||||
let memory = null;
|
||||
const syncFlights = new Map();
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function valid(order) {
|
||||
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
|
||||
new Set(order).size === DEFAULT_ORDER.length &&
|
||||
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
|
||||
}
|
||||
|
||||
function read() {
|
||||
const accountKey = key();
|
||||
if (!accountKey || !storage) return fresh();
|
||||
if (memory?.key === accountKey) return memory.value;
|
||||
let value = fresh();
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
|
||||
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
|
||||
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
|
||||
value = {
|
||||
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
|
||||
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
|
||||
remote:saved.remote && valid(saved.remote.order) ? {
|
||||
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
} catch (_error) {}
|
||||
memory = {key:accountKey, value};
|
||||
return value;
|
||||
}
|
||||
|
||||
function persist(value) {
|
||||
const accountKey = key();
|
||||
if (!accountKey || !storage) return false;
|
||||
memory = {key:accountKey, value};
|
||||
try {
|
||||
storage.setItem(accountKey, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const value = read();
|
||||
return {
|
||||
revision:value.revision, order:value.order.slice(), pending:value.pending,
|
||||
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function announce(value) {
|
||||
if (options.status) {
|
||||
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
|
||||
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
|
||||
}
|
||||
options.onState?.(snapshot());
|
||||
}
|
||||
|
||||
function getOrder() {
|
||||
return read().order.slice();
|
||||
}
|
||||
|
||||
function save(order) {
|
||||
if (!key() || !valid(order)) return false;
|
||||
const current = read();
|
||||
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
|
||||
if (!persist(value)) return false;
|
||||
options.onChange?.(order.slice());
|
||||
announce(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
function move(name, delta) {
|
||||
const order = getOrder();
|
||||
const index = order.indexOf(name);
|
||||
const next = index + (Number(delta) < 0 ? -1 : 1);
|
||||
if (index < 0 || next < 0 || next >= order.length) return order;
|
||||
[order[index], order[next]] = [order[next], order[index]];
|
||||
save(order);
|
||||
return order.slice();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const order = DEFAULT_ORDER.slice();
|
||||
if (fetchJson && key()) save(order);
|
||||
else {
|
||||
const accountKey = key();
|
||||
if (accountKey && storage) {
|
||||
try { storage.removeItem(accountKey); } catch (_error) {}
|
||||
}
|
||||
memory = null;
|
||||
options.onChange?.(order.slice());
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function adopt(snapshotValue, accountKey = key()) {
|
||||
if (!accountKey || key() !== accountKey) return snapshot();
|
||||
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
|
||||
throw new Error('Queue priority response is invalid.');
|
||||
}
|
||||
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
|
||||
persist(value);
|
||||
options.onChange?.(value.order.slice());
|
||||
announce(value);
|
||||
render();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const accountKey = key();
|
||||
if (!fetchJson || !accountKey) return snapshot();
|
||||
try {
|
||||
const remote = await fetchJson('api/v1/queue-priority');
|
||||
if (key() !== accountKey) return snapshot();
|
||||
const local = read();
|
||||
if (local.pending) {
|
||||
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
|
||||
persist(local);
|
||||
return sync();
|
||||
}
|
||||
return adopt(remote, accountKey);
|
||||
} catch (_error) {
|
||||
if (key() !== accountKey) return snapshot();
|
||||
const current = read();
|
||||
current.status = current.pending ? 'pending' : 'error';
|
||||
persist(current); announce(current);
|
||||
return snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(accountKey) {
|
||||
while (key() === accountKey) {
|
||||
const current = read();
|
||||
if (!current.pending) return snapshot();
|
||||
const sent = {revision:current.revision, order:current.order.slice()};
|
||||
current.status = 'syncing'; persist(current); announce(current);
|
||||
try {
|
||||
const saved = await fetchJson('api/v1/queue-priority', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(sent),
|
||||
});
|
||||
if (key() !== accountKey) return snapshot();
|
||||
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
|
||||
throw new Error('Queue priority response is invalid.');
|
||||
}
|
||||
const latest = read();
|
||||
if (latest.order.some((name, index) => name !== sent.order[index])) {
|
||||
latest.revision = saved.revision; latest.pending = true;
|
||||
latest.status = 'pending'; latest.remote = null;
|
||||
persist(latest); announce(latest);
|
||||
continue;
|
||||
}
|
||||
return adopt(saved, accountKey);
|
||||
} catch (error) {
|
||||
if (key() !== accountKey) return snapshot();
|
||||
const latest = read();
|
||||
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
|
||||
latest.status = 'conflict'; latest.pending = true;
|
||||
latest.remote = {revision:remote.revision, order:remote.order.slice()};
|
||||
} else {
|
||||
latest.status = 'pending'; latest.pending = true;
|
||||
}
|
||||
persist(latest); announce(latest); render();
|
||||
return snapshot();
|
||||
}
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const accountKey = key();
|
||||
const current = read();
|
||||
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
|
||||
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
|
||||
const flight = drain(accountKey).finally(() => {
|
||||
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
|
||||
});
|
||||
syncFlights.set(accountKey, flight);
|
||||
return flight;
|
||||
}
|
||||
|
||||
async function useLocal() {
|
||||
const current = read();
|
||||
if (!current.remote) return snapshot();
|
||||
current.revision = current.remote.revision;
|
||||
current.remote = null; current.pending = true; current.status = 'pending';
|
||||
persist(current);
|
||||
return sync();
|
||||
}
|
||||
|
||||
function useRemote() {
|
||||
const current = read();
|
||||
return current.remote ? adopt(current.remote) : snapshot();
|
||||
}
|
||||
|
||||
function displayName(name) {
|
||||
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!options.list || !documentRef) return getOrder();
|
||||
const order = getOrder();
|
||||
const signedIn = Boolean(key());
|
||||
const rows = order.map((name, index) => {
|
||||
const row = documentRef.createElement('div');
|
||||
row.setAttribute('data-queue-priority', name);
|
||||
row.setAttribute('class', 'mobile-queue-priority-row');
|
||||
const label = documentRef.createElement('span');
|
||||
label.textContent = displayName(name);
|
||||
const controls = documentRef.createElement('span');
|
||||
controls.setAttribute('class', 'mobile-queue-priority-controls');
|
||||
const earlier = documentRef.createElement('button');
|
||||
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
|
||||
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
|
||||
earlier.disabled = !signedIn || index === 0;
|
||||
earlier.addEventListener('click', () => {
|
||||
move(name, -1); render();
|
||||
});
|
||||
const later = documentRef.createElement('button');
|
||||
later.textContent = 'Later'; later.setAttribute('type', 'button');
|
||||
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
|
||||
later.disabled = !signedIn || index === order.length - 1;
|
||||
later.addEventListener('click', () => {
|
||||
move(name, 1); render();
|
||||
});
|
||||
controls.append(earlier, later); row.append(label, controls);
|
||||
return row;
|
||||
});
|
||||
options.list.replaceChildren(...rows);
|
||||
if (options.resetButton) options.resetButton.disabled = !signedIn;
|
||||
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
|
||||
return order;
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.resetButton?.addEventListener('click', () => {
|
||||
reset(); render();
|
||||
});
|
||||
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
|
||||
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
|
||||
return render();
|
||||
}
|
||||
|
||||
return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
|
||||
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
|
||||
});
|
||||
@@ -70,9 +70,10 @@
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda',
|
||||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
@@ -97,9 +98,9 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda delivery gate attention update filed later draft'.split(' ');
|
||||
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'following', 'filed', 'authored', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
@@ -107,6 +108,9 @@
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
options.queueRows?.following?.setAttribute(
|
||||
'aria-label', 'Following, ' + normalized.following + ' unseen changes'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
@@ -127,7 +131,9 @@
|
||||
+ ', Human Gates ' + normalized.gate
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Following ' + normalized.following
|
||||
+ ', Filed ' + normalized.filed
|
||||
+ ', My PRs ' + normalized.authored
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
|
||||
@@ -270,6 +270,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-priority.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
|
||||
@@ -78,6 +78,9 @@ STORES = (
|
||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||
)),
|
||||
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
|
||||
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
|
||||
)),
|
||||
Store(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
|
||||
@@ -236,7 +236,11 @@ async def revoke_all_sessions() -> None:
|
||||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
return await asyncio.to_thread(
|
||||
_session_store().list_active,
|
||||
session.session_id,
|
||||
principal_id=session.principal_id,
|
||||
)
|
||||
|
||||
|
||||
async def session_management_id(session: Session) -> str:
|
||||
|
||||
@@ -44,7 +44,7 @@ FEATURE_SOURCES = {
|
||||
),
|
||||
"today-timer": (
|
||||
"static/mobile-app-badge.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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.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/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/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-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/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/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-priority.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/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.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",
|
||||
|
||||
+93
-6
@@ -71,6 +71,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
|
||||
from src.push_subscription_store import build_push_subscription_store
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
|
||||
from src.following_store import FollowingStore
|
||||
from src.unfiled_draft_store import (
|
||||
UnfiledDraftConflict,
|
||||
@@ -496,6 +497,15 @@ async def _upstream_identity() -> tuple[int, str]:
|
||||
return principal_id, principal_login
|
||||
|
||||
|
||||
async def _security_principal_id(request: Request) -> int:
|
||||
session = getattr(request.state, "dashboard_session", None)
|
||||
principal_id = getattr(session, "principal_id", None)
|
||||
if isinstance(principal_id, int) and not isinstance(principal_id, bool) and principal_id > 0:
|
||||
return principal_id
|
||||
principal_id, _login = await _upstream_identity()
|
||||
return principal_id
|
||||
|
||||
|
||||
class DashboardSignIn(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
||||
@@ -956,6 +966,11 @@ class SavedSearchCollection(BaseModel):
|
||||
views: list[SavedSearchView] = Field(max_length=20)
|
||||
|
||||
|
||||
class QueuePriorityCollection(BaseModel):
|
||||
revision: int = Field(ge=0)
|
||||
order: list[str] = Field(min_length=9, max_length=9)
|
||||
|
||||
|
||||
class CompletedFiledReviewReceipt(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
@@ -1775,7 +1790,7 @@ async def require_operator_session(request: Request, call_next):
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -1929,6 +1944,7 @@ async def decide_human_gate(
|
||||
target=gate_id,
|
||||
)
|
||||
login = await _human_gate_login(request)
|
||||
principal_id = int(login.partition(":")[0])
|
||||
journal = _security_event_store()
|
||||
operation_id = None
|
||||
try:
|
||||
@@ -1939,6 +1955,7 @@ async def decide_human_gate(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"human_gate_decision",
|
||||
principal_id=principal_id,
|
||||
method=payload.decision,
|
||||
target=gate_id,
|
||||
)
|
||||
@@ -2062,6 +2079,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
principal_id=principal_id,
|
||||
method="token",
|
||||
device_label=payload.device_label,
|
||||
target="dashboard",
|
||||
@@ -2099,12 +2117,16 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
|
||||
@app.get("/api/v1/security-events")
|
||||
async def list_security_events(
|
||||
request: Request,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
cursor: int | None = Query(default=None, ge=1),
|
||||
):
|
||||
try:
|
||||
page = await asyncio.to_thread(
|
||||
_security_event_store().list, limit=limit, cursor=cursor
|
||||
_security_event_store().list,
|
||||
principal_id=await _security_principal_id(request),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
authentication_alerts = await asyncio.to_thread(
|
||||
_login_attempt_store().list_alerts, limit=24
|
||||
@@ -2270,6 +2292,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_enrolled",
|
||||
principal_id=await _security_principal_id(request),
|
||||
method="passkey",
|
||||
device_label=current.device_label,
|
||||
target="passkey",
|
||||
@@ -2381,6 +2404,7 @@ async def revoke_enrolled_passkey(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
device_label=credential.device_label,
|
||||
target="passkey",
|
||||
)
|
||||
@@ -2556,6 +2580,7 @@ async def verify_passkey_authorization(
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
principal_id=await _security_principal_id(request),
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target=f"{payload.action}:{payload.target}",
|
||||
@@ -2667,6 +2692,7 @@ async def verify_passkey_authentication(
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
principal_id=stored.principal_id,
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="sign_in:dashboard",
|
||||
@@ -2705,6 +2731,7 @@ async def verify_passkey_authentication(
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
principal_id=stored.principal_id,
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="dashboard",
|
||||
@@ -3081,6 +3108,12 @@ def _saved_search_store() -> SavedSearchStore:
|
||||
)
|
||||
|
||||
|
||||
def _queue_priority_store() -> QueuePriorityStore:
|
||||
return QueuePriorityStore(
|
||||
os.getenv("STACKCHAIN_QUEUE_PRIORITY_DB", str(_state_dir / "queue-priority.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
def _following_store() -> FollowingStore:
|
||||
return FollowingStore(
|
||||
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
||||
@@ -3348,6 +3381,46 @@ async def replace_saved_searches(payload: SavedSearchCollection):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/queue-priority")
|
||||
async def get_queue_priority(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_queue_priority_store().get, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Queue priority synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.put("/api/v1/queue-priority")
|
||||
async def replace_queue_priority(payload: QueuePriorityCollection):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_queue_priority_store().replace, login, payload.revision, payload.order
|
||||
)
|
||||
except QueuePriorityConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Queue priority changed on another device.",
|
||||
"snapshot": exc.snapshot,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Queue priority synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/unfiled-drafts")
|
||||
async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
@@ -3806,6 +3879,7 @@ async def save_today_recap_and_log_time(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"gitea_time_logged",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -3906,6 +3980,7 @@ async def sign_out(request: Request, response: Response):
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"sign_out",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target="current_device",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -4016,6 +4091,7 @@ async def revoke_active_device(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"device_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
device_label=target.device_label,
|
||||
target="device",
|
||||
)
|
||||
@@ -4027,7 +4103,9 @@ async def revoke_active_device(
|
||||
)
|
||||
try:
|
||||
revoked = await asyncio.to_thread(
|
||||
_passkey_store().revoke_device_access, management_id
|
||||
_passkey_store().revoke_device_access,
|
||||
management_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
@@ -4066,6 +4144,7 @@ async def sign_out_all_devices(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"all_sessions_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target="all_devices",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -4075,7 +4154,7 @@ async def sign_out_all_devices(
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
management_ids = await asyncio.to_thread(
|
||||
_passkey_store().revoke_all_access,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
@@ -4085,7 +4164,7 @@ async def sign_out_all_devices(
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
await asyncio.to_thread(_push_subscription_store.delete_all)
|
||||
await asyncio.to_thread(_push_subscription_store.delete_sessions, management_ids)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
@@ -6639,6 +6718,7 @@ async def _delete_conversation_comment(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"comment_deleted",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -7094,6 +7174,7 @@ async def close_assigned_issue(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"issue_closed",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -7999,6 +8080,7 @@ async def merge_assigned_pull(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"pull_merged",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -8090,6 +8172,7 @@ async def delete_merged_source_branch(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"source_branch_deleted",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -8277,7 +8360,10 @@ async def prepare_release_rollback(
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve, "release_rollback_prepared", target=target
|
||||
journal.reserve,
|
||||
"release_rollback_prepared",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
@@ -8375,6 +8461,7 @@ async def submit_review(
|
||||
if submission.decision == "approve"
|
||||
else "pull_review_changes_requested"
|
||||
),
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
||||
+20
-6
@@ -322,13 +322,14 @@ class PasskeyStore:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_device_access(self, management_id: str) -> bool:
|
||||
def revoke_device_access(self, management_id: str, *, principal_id: int) -> bool:
|
||||
"""Atomically remove one active session, its grants, and its linked passkey."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
"SELECT session_hash FROM active_sessions "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
return False
|
||||
@@ -336,19 +337,31 @@ class PasskeyStore:
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
"DELETE FROM passkey_credentials "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
"DELETE FROM active_sessions "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self, *, principal_id: int) -> None:
|
||||
def revoke_all_access(self, *, principal_id: int) -> list[str]:
|
||||
"""Atomically remove one principal's passkeys, grants, and active sessions."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
management_ids = [
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT management_id FROM active_sessions "
|
||||
"WHERE principal_id = ? ORDER BY management_id",
|
||||
(principal_id,),
|
||||
).fetchall()
|
||||
]
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
@@ -362,5 +375,6 @@ class PasskeyStore:
|
||||
"DELETE FROM active_sessions WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
return management_ids
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -133,6 +133,9 @@ class DisabledPushSubscriptionStore:
|
||||
def delete_session(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_sessions(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_all(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -442,6 +445,17 @@ class PushSubscriptionStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
|
||||
def delete_sessions(self, session_ids) -> None:
|
||||
requested = set(session_ids)
|
||||
if not requested:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
f"DELETE FROM push_subscriptions WHERE session_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
)
|
||||
|
||||
def delete_all(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Encrypted, revisioned mobile routine queue priority."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
DEFAULT_QUEUE_ORDER = (
|
||||
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft",
|
||||
)
|
||||
|
||||
|
||||
class QueuePriorityConflict(ValueError):
|
||||
"""Raised when a stale client attempts to replace the queue order."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("queue priority changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class QueuePriorityStore:
|
||||
def __init__(self, path: str | Path, *, timeout: float = 1.0, encryption_key: bytes | None = None):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="queue-priority",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS queue_priorities ("
|
||||
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, queue_order TEXT NOT NULL)"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize(order: list[str] | tuple[str, ...]) -> list[str]:
|
||||
if not isinstance(order, (list, tuple)) or len(order) != len(DEFAULT_QUEUE_ORDER):
|
||||
raise ValueError("order must contain every routine queue")
|
||||
if any(not isinstance(name, str) for name in order) or set(order) != set(DEFAULT_QUEUE_ORDER):
|
||||
raise ValueError("order must contain every routine queue exactly once")
|
||||
return list(order)
|
||||
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}, False
|
||||
order, legacy = self._cipher.open(row[1], binding=f"order:{login}")
|
||||
try:
|
||||
normalized = self._normalize(order)
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
return {"revision": int(row[0]), "order": normalized}, legacy
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE queue_priorities SET queue_order = ? WHERE login = ? AND queue_order = ?",
|
||||
(self._cipher.seal(snapshot["order"], binding=f"order:{login}"), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def replace(self, login: str, expected_revision: int, order: list[str]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(order)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if current["revision"] != expected_revision:
|
||||
raise QueuePriorityConflict(current)
|
||||
revision = expected_revision + 1
|
||||
sealed = self._cipher.seal(normalized, binding=f"order:{login}")
|
||||
connection.execute(
|
||||
"INSERT INTO queue_priorities(login, revision, queue_order) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, queue_order=excluded.queue_order",
|
||||
(login, revision, sealed),
|
||||
)
|
||||
return {"revision": revision, "order": normalized}
|
||||
+26
-10
@@ -76,6 +76,7 @@ class SecurityEventStore:
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
@@ -93,6 +94,10 @@ class SecurityEventStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
if "principal_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN principal_id INTEGER"
|
||||
)
|
||||
plaintext_columns = {"kind", "method", "device_label", "target"}
|
||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||
self._migrate_plaintext(
|
||||
@@ -102,6 +107,10 @@ class SecurityEventStore:
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_principal "
|
||||
"ON security_events(principal_id, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
@@ -126,6 +135,7 @@ class SecurityEventStore:
|
||||
CREATE TABLE security_events_encrypted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
@@ -145,7 +155,8 @@ class SecurityEventStore:
|
||||
) in rows:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events_encrypted "
|
||||
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
|
||||
"(id, payload, principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, NULL, ?, ?, ?)",
|
||||
(
|
||||
event_id,
|
||||
payload
|
||||
@@ -195,6 +206,7 @@ class SecurityEventStore:
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
@@ -203,8 +215,9 @@ class SecurityEventStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
||||
(now,),
|
||||
"INSERT INTO security_events(principal_id, created_at, status) "
|
||||
"VALUES (?, ?, 'completed')",
|
||||
(principal_id, now),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
payload = self._seal_event(
|
||||
@@ -224,6 +237,7 @@ class SecurityEventStore:
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
@@ -233,9 +247,9 @@ class SecurityEventStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
||||
"VALUES (?, 'pending', ?)",
|
||||
(now, operation_id),
|
||||
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, 'pending', ?)",
|
||||
(principal_id, now, operation_id),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
connection.execute(
|
||||
@@ -282,12 +296,14 @@ class SecurityEventStore:
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
||||
def list(
|
||||
self, *, principal_id: int, limit: int = 50, cursor: int | None = None
|
||||
) -> SecurityEventPage:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
parameters: list[int] = [principal_id]
|
||||
where = "WHERE principal_id = ?"
|
||||
if cursor is not None:
|
||||
where = "WHERE id < ?"
|
||||
where += " AND id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
|
||||
@@ -259,16 +259,18 @@ class SessionStore:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
|
||||
def list_active(
|
||||
self, current_session_id: str, *, principal_id: int
|
||||
) -> list[ActiveDevice]:
|
||||
now = int(self.clock())
|
||||
current_hash = self._digest(current_session_id)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, device_label, created_at, expires_at, session_hash "
|
||||
"FROM active_sessions WHERE expires_at > ? "
|
||||
"FROM active_sessions WHERE expires_at > ? AND principal_id = ? "
|
||||
"ORDER BY expires_at DESC, created_at DESC",
|
||||
(now,),
|
||||
(now, principal_id),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
+14
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||
@@ -10,4 +12,15 @@ os.environ.setdefault(
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stable_upstream_identity(monkeypatch):
|
||||
"""Keep API tests off the network when security ownership resolves identity."""
|
||||
from src import main
|
||||
|
||||
async def current_user():
|
||||
return {"id": 42, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", current_user)
|
||||
@@ -59,3 +59,173 @@ def test_adaptive_queues_put_truthful_next_action_above_the_fold(viewport):
|
||||
expect(page.locator(".mobile-queue-all")).not_to_have_attribute("open", "")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
@pytest.mark.parametrize(("queue", "count", "label", "destination"), [
|
||||
("following", 3, "Review Following (3)", ["following"]),
|
||||
("authored", 2, "Open My PRs (2)", ["filter:authored", "open:authored"]),
|
||||
])
|
||||
def test_adaptive_queues_open_existing_following_and_authored_work(viewport, queue, count, label, destination):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""({queue, count}) => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.destinations = [];
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({[queue]:count}),
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
openFollowing:() => window.destinations.push('following'),
|
||||
selectFilter:name => window.destinations.push('filter:' + name),
|
||||
firstAction:name => name === 'authored' ? {click:() => window.destinations.push('open:authored')} : null,
|
||||
announce:message => window.destinations.push('announce:' + message),
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-next-action').addEventListener(
|
||||
'click', () => window.adaptiveQueueLauncher.continueWork()
|
||||
);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""", {"queue": queue, "count": count})
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text(label)
|
||||
expect(next_action).to_have_attribute("aria-label", "Next up: " + label)
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == [queue]
|
||||
|
||||
next_action.focus()
|
||||
next_action.press("Enter")
|
||||
assert page.evaluate("window.destinations") == destination
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
|
||||
viewport = {"width": 390, "height": 844}
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.online = false;
|
||||
window.destinations = [];
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({delivery:1, gate:2, today:3}),
|
||||
isOnline:() => window.online,
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
openToday:() => window.destinations.push('today'),
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-next-action').addEventListener(
|
||||
'click', () => window.adaptiveQueueLauncher.continueWork()
|
||||
);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text("Continue Today (3)")
|
||||
next_action.press("Enter")
|
||||
assert page.evaluate("window.destinations") == ["today"]
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.online = true;
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
}""")
|
||||
expect(next_action).to_have_text("Recover Delivery (1)")
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
def test_operator_reorders_routine_queues_without_leaking_priority_between_accounts(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-priority.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
window.login = 'alice';
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.launcher = createMobileQueueLauncher({
|
||||
getCounts:() => ({attention:2, following:3, authored:1}),
|
||||
getRoutineOrder:() => window.priority.getOrder(), rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
});
|
||||
window.priority = createMobileQueuePriority({
|
||||
storage:{getItem:key => values.get(key) || null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:() => window.login, document,
|
||||
list:document.querySelector('#mobile-queue-priority-list'),
|
||||
resetButton:document.querySelector('#reset-mobile-queue-priority'),
|
||||
status:document.querySelector('#mobile-queue-priority-status'),
|
||||
labels:{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'},
|
||||
onChange:() => window.launcher.renderPresentation(),
|
||||
});
|
||||
window.priority.start(); window.launcher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-priority').open = true;
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
for _ in range(4):
|
||||
page.get_by_role("button", name="Move Following earlier").click()
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Review Following (3)")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["following", "attention", "authored"]
|
||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
|
||||
controls = page.locator(".mobile-queue-priority-controls button")
|
||||
assert controls.count() == 18
|
||||
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.login = 'bob'; window.priority.render(); window.launcher.renderPresentation();
|
||||
}""")
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Start Attention (2)")
|
||||
assert page.locator("#mobile-queue-priority-list [data-queue-priority]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.queuePriority)"
|
||||
)[:3] == ["attention", "today", "update"]
|
||||
browser.close()
|
||||
@@ -99,6 +99,8 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
||||
2_000,
|
||||
management_id=f"{slug}-management-id",
|
||||
device_label=label,
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"{slug}-session",
|
||||
@@ -122,7 +124,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_device_access("phone-management-id")
|
||||
passkeys.revoke_device_access("phone-management-id", principal_id=42)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
@@ -132,3 +134,69 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
||||
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
|
||||
|
||||
|
||||
def test_device_access_revocation_cannot_cross_principal_boundary(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"session-{principal_id}",
|
||||
action="close_issue",
|
||||
target="stackchain/dashboard#1",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"credential-{principal_id}".encode(),
|
||||
public_key=f"public-key-{principal_id}".encode(),
|
||||
sign_count=0,
|
||||
device_label=f"Device {principal_id}",
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
)
|
||||
|
||||
assert passkeys.revoke_device_access("management-84", principal_id=42) is False
|
||||
assert passkeys.revoke_device_access("management-42", principal_id=42) is True
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM passkey_credentials ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_global_access_revocation_returns_only_affected_management_ids(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
for suffix in ("phone", "laptop"):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}-{suffix}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}-{suffix}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
|
||||
affected = passkeys.revoke_all_access(principal_id=42)
|
||||
|
||||
assert affected == ["management-42-laptop", "management-42-phone"]
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM active_sessions ORDER BY management_id"
|
||||
).fetchall() == [
|
||||
("management-84-laptop",),
|
||||
("management-84-phone",),
|
||||
]
|
||||
@@ -135,7 +135,8 @@ async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization
|
||||
def record(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
assert principal_id == 42
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "cleanup-operation"
|
||||
|
||||
@@ -415,7 +416,7 @@ async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||
},
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
events = main._security_event_store().list(principal_id=42, limit=10).events
|
||||
assert denied.status_code == 401
|
||||
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||
@@ -470,7 +471,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
events = main._security_event_store().list(principal_id=42, limit=10).events
|
||||
assert options.status_code == 200
|
||||
assert denied.status_code == 401
|
||||
assert "grant" not in denied.json()
|
||||
|
||||
@@ -275,7 +275,7 @@ const feature=createFollowing({{
|
||||
]
|
||||
|
||||
|
||||
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||
def test_following_opens_explicitly_and_becomes_work_recommendation():
|
||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(launcher))});
|
||||
@@ -293,7 +293,7 @@ process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommend
|
||||
).stdout)
|
||||
assert result == {
|
||||
"opened": "opened-following",
|
||||
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"recommended": {"name": "following", "count": 7, "label": "Review Following (7)"},
|
||||
"calls": ["following"],
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ async def test_successful_decision_records_one_completed_privacy_safe_security_e
|
||||
headers={"Idempotency-Key": "decision-audited"},
|
||||
)
|
||||
|
||||
events = journal.list().events
|
||||
events = journal.list(principal_id=1).events
|
||||
assert decided.status_code == 201
|
||||
assert [
|
||||
{"kind": event.kind, "method": event.method, "target": event.target, "status": event.status}
|
||||
@@ -282,7 +282,7 @@ async def test_rejected_decision_discards_its_pending_security_event(
|
||||
)
|
||||
|
||||
assert rejected.status_code == 409
|
||||
assert journal.list().events == []
|
||||
assert journal.list(principal_id=1).events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -10,6 +10,7 @@ from tests.dashboard_bundle import dashboard
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
|
||||
QUEUE_PRIORITY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-priority.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
@@ -48,6 +49,215 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_priority_persists_complete_account_scoped_routine_order():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
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),
|
||||
}};
|
||||
let login = 'alice';
|
||||
const priority = createPriority({{storage, getLogin:() => login}});
|
||||
const original = priority.getOrder();
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
const alice = priority.getOrder();
|
||||
login = 'bob';
|
||||
const bob = priority.getOrder();
|
||||
login = '';
|
||||
const anonymous = priority.getOrder();
|
||||
login = 'alice';
|
||||
priority.reset();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
original, alice, bob, anonymous, reset:priority.getOrder(), keys:Array.from(values.keys()),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
default = ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"]
|
||||
assert json.loads(result.stdout) == {
|
||||
"original": default,
|
||||
"alice": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"bob": default,
|
||||
"anonymous": default,
|
||||
"reset": default,
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_priority_hydrates_syncs_and_preserves_local_order_on_conflict():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map();
|
||||
const defaults = ['attention','today','update','agenda','following','authored','filed','later','draft'];
|
||||
const remote = defaults.slice(); remote.splice(remote.indexOf('following'), 1); remote.splice(1, 0, 'following');
|
||||
let server = {{revision:2, order:remote.slice()}};
|
||||
let conflict = false;
|
||||
const calls = [];
|
||||
const fetchJson = async (url, init={{}}) => {{
|
||||
calls.push([url, init.method || 'GET']);
|
||||
if (!init.method) return JSON.parse(JSON.stringify(server));
|
||||
const payload = JSON.parse(init.body);
|
||||
if (conflict) {{
|
||||
const error = new Error('conflict'); error.status=409;
|
||||
error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(server))}}}};
|
||||
throw error;
|
||||
}}
|
||||
server={{revision:payload.revision + 1, order:payload.order.slice()}};
|
||||
return JSON.parse(JSON.stringify(server));
|
||||
}};
|
||||
(async () => {{
|
||||
const priority=createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', fetchJson,
|
||||
}});
|
||||
await priority.load();
|
||||
const hydrated=priority.getOrder();
|
||||
priority.move('authored', -1);
|
||||
const local=priority.getOrder();
|
||||
conflict=true;
|
||||
server={{revision:3, order:defaults.slice()}};
|
||||
await priority.sync();
|
||||
const conflicted=priority.state();
|
||||
conflict=false;
|
||||
await priority.useLocal();
|
||||
process.stdout.write(JSON.stringify({{hydrated,local,conflicted,settled:priority.state(),server,calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["hydrated"][1] == "following"
|
||||
assert payload["local"] == payload["conflicted"]["order"]
|
||||
assert payload["conflicted"]["status"] == "conflict"
|
||||
assert payload["conflicted"]["remote"] == {
|
||||
"revision": 3,
|
||||
"order": ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"],
|
||||
}
|
||||
assert payload["settled"]["status"] == "ready"
|
||||
assert payload["server"]["revision"] == 4
|
||||
assert payload["server"]["order"] == payload["local"]
|
||||
assert payload["calls"] == [
|
||||
["api/v1/queue-priority", "GET"],
|
||||
["api/v1/queue-priority", "PUT"],
|
||||
["api/v1/queue-priority", "PUT"],
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_queue_priority_serializes_rapid_edits_and_publishes_latest_order():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map(); const pending=[]; const payloads=[];
|
||||
const fetchJson = async (_url, init) => new Promise(resolve => {{
|
||||
const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}}));
|
||||
}});
|
||||
(async () => {{
|
||||
let priority;
|
||||
priority=createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }},
|
||||
}});
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const firstPending=pending.length;
|
||||
pending.shift()();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const secondPending=pending.length;
|
||||
pending.shift()();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{firstPending,secondPending,payloads,state:priority.state()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["firstPending"] == 1
|
||||
assert payload["secondPending"] == 1
|
||||
assert len(payload["payloads"]) == 2
|
||||
assert payload["payloads"][0]["revision"] == 0
|
||||
assert payload["payloads"][1]["revision"] == 1
|
||||
assert payload["payloads"][1]["order"] == payload["state"]["order"]
|
||||
assert payload["state"]["revision"] == 2
|
||||
assert payload["state"]["status"] == "ready"
|
||||
|
||||
|
||||
def test_mobile_queue_priority_discards_in_flight_results_after_account_switch():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map(); let login='alice'; let rejectLoad;
|
||||
const fetchJson = () => new Promise((_resolve, reject) => {{ rejectLoad=reject; }});
|
||||
(async () => {{
|
||||
const priority=createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>login, fetchJson,
|
||||
}});
|
||||
const loading=priority.load();
|
||||
login='bob';
|
||||
rejectLoad(new Error('alice offline'));
|
||||
await loading;
|
||||
process.stdout.write(JSON.stringify({{state:priority.state(),keys:Array.from(values.keys())}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["state"]["status"] == "ready"
|
||||
assert payload["state"]["order"] == [
|
||||
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"
|
||||
]
|
||||
assert payload["keys"] == []
|
||||
|
||||
|
||||
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
class Element {{
|
||||
constructor(tag='div') {{ this.tag=tag; this.children=[]; this.listeners={{}}; this.attributes={{}}; this.disabled=false; this.textContent=''; }}
|
||||
append(...items) {{ this.children.push(...items); }}
|
||||
replaceChildren(...items) {{ this.children=[...items]; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
click() {{ this.listeners.click?.(); }}
|
||||
}}
|
||||
const values = new Map();
|
||||
const list = new Element(); const resetButton = new Element('button'); const status = new Element();
|
||||
const priority = createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', document:{{createElement:tag=>new Element(tag)}}, list, resetButton, status,
|
||||
labels:{{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'}},
|
||||
}});
|
||||
priority.start();
|
||||
for (let index=0; index<3; index += 1) {{
|
||||
const row = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
|
||||
row.children[1].children[0].click();
|
||||
}}
|
||||
const following = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
order:list.children.map(item => item.attributes['data-queue-priority']),
|
||||
earlierLabel:following.children[1].children[0].attributes['aria-label'],
|
||||
laterLabel:following.children[1].children[1].attributes['aria-label'],
|
||||
status:status.textContent,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"earlierLabel": "Move Following earlier",
|
||||
"laterLabel": "Move Following later",
|
||||
"status": "Sync pending.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
@@ -300,6 +510,76 @@ async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_d
|
||||
assert html.count(f'<button data-mobile-queue="{name}"') == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-priority"' in html
|
||||
assert 'id="mobile-queue-priority-list"' in html
|
||||
assert 'id="reset-mobile-queue-priority"' in html
|
||||
assert 'id="mobile-queue-priority-status" role="status" aria-live="polite"' in html
|
||||
assert '<script src="static/mobile-queue-priority.js"></script>' in html
|
||||
assert "getRoutineOrder: () => mobileQueuePriority?.getOrder()" in html
|
||||
assert "createMobileQueuePriority({" in html
|
||||
assert "getLogin: () => confirmedOwnerLogin" in html
|
||||
assert "mobileQueuePriority.render();\n renderMobileQueuePresentation();" in html
|
||||
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_conflict_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-priority-conflict"' in html
|
||||
assert 'id="keep-local-mobile-queue-priority"' in html
|
||||
assert 'id="use-remote-mobile-queue-priority"' in html
|
||||
assert "fetchJson: fetchReviewJson" in html
|
||||
assert "void mobileQueuePriority.load();" in html
|
||||
assert "void mobileQueuePriority.sync();" in html
|
||||
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
|
||||
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
|
||||
assert "error.payload = payload;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let offlineWorkMode = false;" in html
|
||||
assert "isOnline: () => !offlineWorkMode" in html
|
||||
assert (
|
||||
"offlineWorkMode = value;\n"
|
||||
" renderMobileQueuePresentation();"
|
||||
) in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_hydration_refreshes_the_persistent_mobile_queue_census():
|
||||
html = await dashboard()
|
||||
|
||||
assert (
|
||||
"preparationItems.following = items.filter(item => item.has_unseen_change === true);\n"
|
||||
" renderMobileQueuePresentation();\n"
|
||||
" mobileTaskDock.updateQueues(queueCounts);\n"
|
||||
" mobileStartDay.render();"
|
||||
) in html
|
||||
assert (
|
||||
"queueCounts.followingUnavailable = status === 'error';\n"
|
||||
" renderMobileQueuePresentation();\n"
|
||||
" mobileTaskDock.updateQueues(queueCounts);\n"
|
||||
" mobileStartDay.render();"
|
||||
) in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_work_refresh_preserves_the_hydrated_following_queue():
|
||||
html = await dashboard()
|
||||
|
||||
assert "counts.following = queueCounts.following;" in html
|
||||
assert "counts.followingUnavailable = queueCounts.followingUnavailable;" in html
|
||||
assert "following:preparationItems.following || []," in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
|
||||
html = await dashboard()
|
||||
@@ -548,7 +828,7 @@ process.stdout.write(JSON.stringify({{
|
||||
"populated": {
|
||||
"badge": "8 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Human Gates 2, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 8 active queues",
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Human Gates 2, Attention 1, Updates 5, Following 0, Filed 2, My PRs 0, Later 3, Drafts 4; 8 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
@@ -557,7 +837,7 @@ process.stdout.write(JSON.stringify({{
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 7, Following 0, Filed 0, My PRs 0, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
@@ -570,6 +850,59 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_counts_following_and_authored_work_truthfully():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const node = () => ({{
|
||||
textContent:'', hidden:false, attributes:{{}},
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
}});
|
||||
const queues = node();
|
||||
const badge = node();
|
||||
const deadline = node();
|
||||
const following = node();
|
||||
const authored = node();
|
||||
const dock = createDock({{
|
||||
nav:node(), buttons:{{queues}}, queueBadge:badge, deadlineBadge:deadline,
|
||||
queueRows:{{following, authored}},
|
||||
queueCounts:{{following:node(), authored:node()}},
|
||||
}});
|
||||
dock.updateQueues({{following:3, authored:2}});
|
||||
const active = {{
|
||||
badge:badge.textContent,
|
||||
badgeHidden:badge.hidden,
|
||||
summary:queues.attributes['aria-label'],
|
||||
followingCount:dock ? following.attributes['aria-label'] : null,
|
||||
}};
|
||||
dock.updateQueues({{following:0, authored:0}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
active,
|
||||
cleared:{{
|
||||
badgeHidden:badge.hidden,
|
||||
summary:queues.attributes['aria-label'],
|
||||
followingLabel:following.attributes['aria-label'],
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"active": {
|
||||
"badge": "2 active",
|
||||
"badgeHidden": False,
|
||||
"summary": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 0, Following 3, Filed 0, My PRs 2, Later 0, Drafts 0; 2 active queues",
|
||||
"followingCount": "Following, 3 unseen changes",
|
||||
},
|
||||
"cleared": {
|
||||
"badgeHidden": True,
|
||||
"summary": "Queues: no active queues; no upcoming deadlines",
|
||||
"followingLabel": "Following, 0 unseen changes",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -690,6 +1023,36 @@ process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}))
|
||||
}
|
||||
|
||||
|
||||
def test_adaptive_mobile_work_skips_online_only_queues_offline_and_restores_them_online():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let online = false;
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => ({{delivery:2, gate:1, today:3, update:4}}),
|
||||
isOnline: () => online,
|
||||
openToday: () => {{ calls.push('today'); return 'opened-today'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
}});
|
||||
const offline = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
online = true;
|
||||
const reconnected = launcher.recommend();
|
||||
process.stdout.write(JSON.stringify({{offline, opened, reconnected, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"offline": {"name": "today", "count": 3, "label": "Continue Today (3)"},
|
||||
"opened": "opened-today",
|
||||
"reconnected": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
|
||||
"calls": ["today"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -717,6 +1080,42 @@ process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_continues_following_then_authored_before_claiming_new_work():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{following:3, authored:2, filed:1, later:4}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openFollowing: () => {{ calls.push('following'); return 'opened-following'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => name === 'authored' ? {{click() {{ calls.push('open:authored'); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const following = launcher.recommend();
|
||||
const openedFollowing = launcher.continueWork();
|
||||
counts = {{following:0, authored:2, filed:1, later:4}};
|
||||
const authored = launcher.recommend();
|
||||
const openedAuthored = launcher.continueWork();
|
||||
counts = {{following:0, authored:0, filed:0, later:0}};
|
||||
const fallback = launcher.recommend();
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{following, openedFollowing, authored, openedAuthored, fallback, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"following": {"name": "following", "count": 3, "label": "Review Following (3)"},
|
||||
"openedFollowing": "opened-following",
|
||||
"authored": {"name": "authored", "count": 2, "label": "Open My PRs (2)"},
|
||||
"openedAuthored": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["following", "filter:authored", "open:authored", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -752,6 +1151,36 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_applies_routine_priority_without_demoting_safety_queues():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
let online = true;
|
||||
const launcher = createLauncher({{
|
||||
getCounts:() => ({{delivery:1, gate:2, attention:3, following:4, authored:5}}),
|
||||
isOnline:() => online,
|
||||
getRoutineOrder:() => ['following','authored','attention','today','update','agenda','filed','later','draft'],
|
||||
}});
|
||||
const onlineView = launcher.presentation();
|
||||
online = false;
|
||||
const offlineView = launcher.presentation();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
onlineNext:onlineView.nextUp.name,
|
||||
onlineActive:onlineView.active.map(item => item.name),
|
||||
offlineNext:offlineView.nextUp.name,
|
||||
offlineActive:offlineView.active.map(item => item.name),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"onlineNext": "delivery",
|
||||
"onlineActive": ["delivery", "gate", "following", "authored", "attention"],
|
||||
"offlineNext": "following",
|
||||
"offlineActive": ["delivery", "gate", "following", "authored", "attention"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_uses_one_adaptive_action_for_daily_preparation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -893,7 +1322,7 @@ const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[na
|
||||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
|
||||
for (const mode of ['delivery','attention','update','agenda','following','authored','filed','later','draft']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
@@ -907,6 +1336,8 @@ process.stdout.write(JSON.stringify(labels));
|
||||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"following": ["Following", "Open Following"],
|
||||
"authored": ["My PRs", "Open My PRs"],
|
||||
"filed": ["Filed", "Open Filed"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
|
||||
@@ -107,7 +107,9 @@ def test_eager_private_stores_share_the_private_filesystem_boundary(tmp_path, bu
|
||||
@pytest.mark.parametrize(
|
||||
"exercise",
|
||||
[
|
||||
lambda path: SecurityEventStore(path, clock=lambda: 1).record("sign_in"),
|
||||
lambda path: SecurityEventStore(path, clock=lambda: 1).record(
|
||||
"sign_in", principal_id=42
|
||||
),
|
||||
lambda path: LoginAttemptStore(
|
||||
path, clock=lambda: 1, max_failures=3, window_seconds=60
|
||||
).record_failure("203.0.113.10"),
|
||||
|
||||
@@ -1599,7 +1599,7 @@ async def test_assigned_pull_merge_reports_success_and_retains_pending_audit_whe
|
||||
lifecycle = []
|
||||
|
||||
class InterruptedJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
@@ -1640,7 +1640,7 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
|
||||
merged = False
|
||||
|
||||
class LifecycleJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
calls.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
@@ -1782,7 +1782,7 @@ async def test_assigned_pull_merge_discards_audit_reservation_after_definite_rej
|
||||
lifecycle = []
|
||||
|
||||
class LifecycleJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
@@ -2240,7 +2240,7 @@ async def test_source_branch_cleanup_endpoint_audits_and_deletes_the_exact_merge
|
||||
lifecycle = []
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "branch-cleanup"
|
||||
|
||||
@@ -2740,7 +2740,7 @@ async def test_release_rollback_endpoint_requires_failed_exact_commit_and_audits
|
||||
lifecycle = []
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "rollback-operation"
|
||||
|
||||
|
||||
@@ -1985,3 +1985,21 @@ async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavai
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert store.is_subscribed("device-a") is False
|
||||
|
||||
|
||||
def test_delete_sessions_removes_only_the_requested_device_push_state(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("management-42-phone", "management-42-laptop", "management-84-phone"):
|
||||
store.upsert(
|
||||
session_id,
|
||||
{
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
},
|
||||
)
|
||||
|
||||
store.delete_sessions(["management-42-phone", "management-42-laptop"])
|
||||
|
||||
assert store.is_subscribed("management-42-phone") is False
|
||||
assert store.is_subscribed("management-42-laptop") is False
|
||||
assert store.is_subscribed("management-84-phone") is True
|
||||
@@ -0,0 +1,75 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||
|
||||
|
||||
def test_queue_priority_is_revisioned_encrypted_and_account_scoped(tmp_path):
|
||||
database = tmp_path / "queue-priority.sqlite3"
|
||||
store = QueuePriorityStore(database, encryption_key=b"q" * 32)
|
||||
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||
preferred.remove("following")
|
||||
preferred.insert(1, "following")
|
||||
|
||||
created = store.replace(" Timmy ", 0, preferred)
|
||||
|
||||
assert created == {"revision": 1, "order": preferred}
|
||||
assert QueuePriorityStore(database, encryption_key=b"q" * 32).get("timmy") == created
|
||||
assert store.get("alexander") == {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert "following" not in payload
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_queue_priority_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_QUEUE_PRIORITY_DB", str(tmp_path / "queue-priority.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||
preferred.remove("following")
|
||||
preferred.insert(1, "following")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
forbidden = await client.put(
|
||||
"/api/v1/queue-priority", json={"revision": 0, "order": preferred}
|
||||
)
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.put(
|
||||
"/api/v1/queue-priority", json={"revision": 0, "order": preferred}, headers=headers
|
||||
)
|
||||
stale = await client.put(
|
||||
"/api/v1/queue-priority",
|
||||
json={"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)},
|
||||
headers=headers,
|
||||
)
|
||||
fetched = await client.get("/api/v1/queue-priority")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"revision": 1, "order": preferred}
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"] == {
|
||||
"message": "Queue priority changed on another device.",
|
||||
"snapshot": saved.json(),
|
||||
}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -7,6 +7,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||
from src.saved_search_store import SavedSearchStore
|
||||
|
||||
|
||||
@@ -111,3 +112,29 @@ def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tm
|
||||
assert CompletedFiledReviewStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == {"receipts": [private_receipt]}
|
||||
|
||||
|
||||
def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
path = state / "queue-priority.sqlite3"
|
||||
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||
preferred.remove("following")
|
||||
preferred.insert(1, "following")
|
||||
expected = QueuePriorityStore(path, encryption_key=b"o" * 32).replace(
|
||||
"timmy", 0, preferred
|
||||
)
|
||||
|
||||
completed = run_rotation(state)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout)["queue-priority"] == {
|
||||
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||
}
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v2:next:")
|
||||
assert QueuePriorityStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == expected
|
||||
@@ -66,6 +66,32 @@ 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_security_activity_does_not_follow_a_previous_upstream_identity(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as timmy:
|
||||
await timmy.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Timmy phone"},
|
||||
)
|
||||
|
||||
async def other_user():
|
||||
return {"id": 84, "login": "other"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", other_user)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as other:
|
||||
signed_in = await other.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Other phone"},
|
||||
)
|
||||
activity = await other.get("/api/v1/security-events")
|
||||
|
||||
assert signed_in.status_code == 200
|
||||
assert [event["device_label"] for event in activity.json()["events"]] == ["Other phone"]
|
||||
|
||||
|
||||
@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)
|
||||
@@ -211,7 +237,7 @@ async def test_passkey_enrollment_registry_failure_discards_reserved_event(
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
).list(principal_id=42, limit=10).events
|
||||
assert enrolled.status_code == 503
|
||||
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
|
||||
assert all(event.kind != "passkey_enrolled" for event in events)
|
||||
@@ -596,7 +622,8 @@ async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
|
||||
return {"id": 42, "deleted": True}
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
assert principal_id == 42
|
||||
journal_calls.append(("reserve", kind, target))
|
||||
return "operation-42"
|
||||
|
||||
@@ -873,7 +900,7 @@ async def test_sign_out_is_journaled_before_the_session_is_removed(security_acce
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
).list(principal_id=42, limit=10).events
|
||||
assert signed_out.status_code == 200
|
||||
assert [(event.kind, event.device_label) for event in events[:2]] == [
|
||||
("sign_out", None),
|
||||
@@ -974,7 +1001,7 @@ async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_acc
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
).list(principal_id=42, limit=10).events
|
||||
assert response.status_code == 200
|
||||
assert events[0].kind == "all_sessions_revoked"
|
||||
assert events[0].target == "all_devices"
|
||||
|
||||
@@ -12,7 +12,7 @@ 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")
|
||||
store.record("issue_closed", principal_id=42, method="passkey", target="private/repo#42")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
columns = {
|
||||
@@ -22,6 +22,7 @@ def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
|
||||
assert columns == {
|
||||
"id",
|
||||
"payload",
|
||||
"principal_id",
|
||||
"created_at",
|
||||
"status",
|
||||
"operation_id",
|
||||
@@ -38,7 +39,7 @@ def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_pa
|
||||
}
|
||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||
|
||||
store.record(**canaries)
|
||||
store.record(principal_id=42, **canaries)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
@@ -48,13 +49,13 @@ def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_pa
|
||||
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]
|
||||
event = reopened.list(principal_id=42, 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):
|
||||
def test_legacy_security_events_migrate_encrypted_but_remain_unattributed(tmp_path):
|
||||
path = tmp_path / "security.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.executescript(
|
||||
@@ -80,17 +81,8 @@ def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_p
|
||||
)
|
||||
|
||||
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")
|
||||
]
|
||||
assert store.list(principal_id=42, limit=10).events == []
|
||||
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"
|
||||
@@ -105,12 +97,12 @@ def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_p
|
||||
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")
|
||||
store.record("issue_closed", principal_id=42, 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)
|
||||
).list(principal_id=42, limit=10)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
@@ -122,7 +114,7 @@ def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
|
||||
(payload[:-1] + replacement,),
|
||||
)
|
||||
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
||||
store.list(limit=10)
|
||||
store.list(principal_id=42, limit=10)
|
||||
|
||||
|
||||
def test_missing_security_activity_encryption_key_fails_with_store_error(
|
||||
@@ -140,20 +132,21 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
|
||||
|
||||
store.record(
|
||||
"sign_in",
|
||||
principal_id=42,
|
||||
method="token",
|
||||
device_label=" Timmy Phone " + "x" * 80,
|
||||
target="dashboard",
|
||||
)
|
||||
now[0] += 1
|
||||
store.record("device_revoked", device_label="Old phone", target="device")
|
||||
store.record("device_revoked", principal_id=42, device_label="Old phone", target="device")
|
||||
|
||||
page = store.list(limit=1)
|
||||
page = store.list(principal_id=42, limit=1)
|
||||
assert [(event.kind, event.device_label, event.target) for event in page.events] == [
|
||||
("device_revoked", "Old phone", "device")
|
||||
]
|
||||
assert page.next_cursor is not None
|
||||
|
||||
older = store.list(limit=10, cursor=page.next_cursor)
|
||||
older = store.list(principal_id=42, limit=10, cursor=page.next_cursor)
|
||||
assert older.events[0].kind == "sign_in"
|
||||
assert older.events[0].method == "token"
|
||||
assert older.events[0].device_label == ("Timmy Phone " + "x" * 52)
|
||||
@@ -162,10 +155,29 @@ 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", "payload", "created_at", "status", "operation_id",
|
||||
"id", "payload", "principal_id", "created_at", "status", "operation_id",
|
||||
}
|
||||
|
||||
|
||||
def test_security_events_are_isolated_by_principal_across_cursor_pages(tmp_path):
|
||||
now = [1_000]
|
||||
store = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3", clock=lambda: now[0], encryption_key=PRIVATE_KEY
|
||||
)
|
||||
store.record("sign_in", principal_id=42, device_label="Timmy phone")
|
||||
now[0] += 1
|
||||
store.record("sign_in", principal_id=84, device_label="Other phone")
|
||||
now[0] += 1
|
||||
store.record("device_revoked", principal_id=42, device_label="Old Timmy phone")
|
||||
|
||||
first = store.list(principal_id=42, limit=1)
|
||||
second = store.list(principal_id=42, limit=10, cursor=first.next_cursor)
|
||||
|
||||
assert [event.device_label for event in first.events] == ["Old Timmy phone"]
|
||||
assert [event.device_label for event in second.events] == ["Timmy phone"]
|
||||
assert all(event.device_label != "Other phone" for event in first.events + second.events)
|
||||
|
||||
|
||||
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||
now = [0]
|
||||
store = SecurityEventStore(
|
||||
@@ -176,32 +188,32 @@ def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||
)
|
||||
for index in range(4):
|
||||
now[0] = index
|
||||
store.record("sign_in", method="token", device_label=f"Device {index}")
|
||||
store.record("sign_in", principal_id=42, method="token", device_label=f"Device {index}")
|
||||
|
||||
assert [event.device_label for event in store.list(limit=10).events] == [
|
||||
assert [event.device_label for event in store.list(principal_id=42, limit=10).events] == [
|
||||
"Device 3", "Device 2", "Device 1"
|
||||
]
|
||||
|
||||
now[0] = 20
|
||||
store.record("sign_out", device_label="Current")
|
||||
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
||||
store.record("sign_out", principal_id=42, device_label="Current")
|
||||
assert [event.kind for event in store.list(principal_id=42, limit=10).events] == ["sign_out"]
|
||||
|
||||
|
||||
def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
|
||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||
operation_id = store.reserve("issue_closed", principal_id=42, target="stackchain/api#7")
|
||||
|
||||
pending = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3", clock=lambda: 1_001
|
||||
).list(limit=10).events
|
||||
).list(principal_id=42, limit=10).events
|
||||
assert [(event.kind, event.target, event.status) for event in pending] == [
|
||||
("issue_closed", "stackchain/api#7", "pending")
|
||||
]
|
||||
|
||||
store.finalize(operation_id)
|
||||
|
||||
completed = store.list(limit=10).events
|
||||
completed = store.list(principal_id=42, limit=10).events
|
||||
assert [(event.kind, event.target, event.status) for event in completed] == [
|
||||
("issue_closed", "stackchain/api#7", "completed")
|
||||
]
|
||||
@@ -209,8 +221,8 @@ def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||
|
||||
def test_failed_operation_can_discard_its_pending_reservation(tmp_path):
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||
operation_id = store.reserve("issue_closed", principal_id=42, target="stackchain/api#7")
|
||||
|
||||
store.discard(operation_id)
|
||||
|
||||
assert store.list(limit=10).events == []
|
||||
assert store.list(principal_id=42, limit=10).events == []
|
||||
@@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-first-task.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-priority.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
"/dashboard/static/mobile-start-day.js",
|
||||
|
||||
+50
-11
@@ -201,6 +201,38 @@ def test_managed_session_statuses_skips_database_for_an_empty_batch(tmp_path, mo
|
||||
assert store.managed_statuses([], idle_timeout_seconds=900) == {}
|
||||
|
||||
|
||||
def test_active_devices_are_scoped_to_the_signed_in_principal(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate(
|
||||
"matching-session",
|
||||
3_000,
|
||||
management_id="matching-device",
|
||||
device_label="My phone",
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
store.activate(
|
||||
"other-session",
|
||||
3_000,
|
||||
management_id="other-device",
|
||||
device_label="Other phone",
|
||||
principal_id=84,
|
||||
principal_login="other",
|
||||
)
|
||||
store.activate(
|
||||
"legacy-session",
|
||||
3_000,
|
||||
management_id="legacy-device",
|
||||
device_label="Legacy phone",
|
||||
)
|
||||
|
||||
devices = store.list_active("matching-session", principal_id=42)
|
||||
|
||||
assert [(device.management_id, device.current) for device in devices] == [
|
||||
("matching-device", True)
|
||||
]
|
||||
|
||||
|
||||
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
@@ -229,10 +261,14 @@ def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
|
||||
|
||||
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
||||
store.activate("laptop-session-secret", 3_000, device_label="Work laptop")
|
||||
store.activate(
|
||||
"phone-session-secret", 2_000, device_label="Pixel 9", principal_id=42
|
||||
)
|
||||
store.activate(
|
||||
"laptop-session-secret", 3_000, device_label="Work laptop", principal_id=42
|
||||
)
|
||||
|
||||
devices = store.list_active("phone-session-secret")
|
||||
devices = store.list_active("phone-session-secret", principal_id=42)
|
||||
|
||||
assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"]
|
||||
assert [device.current for device in devices] == [False, True]
|
||||
@@ -243,9 +279,13 @@ def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||
|
||||
def test_revoke_managed_device_removes_only_the_selected_session(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone", 2_000, device_label="Phone")
|
||||
store.activate("laptop", 2_000, device_label="Laptop")
|
||||
phone = next(device for device in store.list_active("laptop") if device.device_label == "Phone")
|
||||
store.activate("phone", 2_000, device_label="Phone", principal_id=42)
|
||||
store.activate("laptop", 2_000, device_label="Laptop", principal_id=42)
|
||||
phone = next(
|
||||
device
|
||||
for device in store.list_active("laptop", principal_id=42)
|
||||
if device.device_label == "Phone"
|
||||
)
|
||||
|
||||
assert store.revoke_managed(phone.management_id) is True
|
||||
assert store.is_active("phone", 2_000) is False
|
||||
@@ -266,9 +306,8 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
|
||||
store.activate("new-session", 3_000, device_label="New phone")
|
||||
|
||||
assert store.is_active("existing-session", 2_000) is True
|
||||
devices = store.list_active("existing-session")
|
||||
assert len(devices) == 2
|
||||
assert next(device for device in devices if device.current).device_label == "Existing device"
|
||||
devices = store.list_active("existing-session", principal_id=42)
|
||||
assert devices == []
|
||||
|
||||
|
||||
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
|
||||
@@ -388,11 +427,11 @@ def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path):
|
||||
|
||||
def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session", 2_000, device_label="Phone")
|
||||
store.activate("phone-session", 2_000, device_label="Phone", principal_id=42)
|
||||
grant = store.mint_step_up(
|
||||
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
|
||||
)
|
||||
phone = store.list_active("phone-session")[0]
|
||||
phone = store.list_active("phone-session", principal_id=42)[0]
|
||||
|
||||
assert store.revoke_managed(phone.management_id) is True
|
||||
assert store.consume_step_up(
|
||||
|
||||
Reference in new issue
Block a user