Compare commits

..
Author SHA1 Message Date
timmy b18aa5ed63 feat: sync mobile queue priority across devices (Closes #1464)
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 09:27:27 +00:00
timmy dbc305108a Merge pull request 'Scope Security activity to the signed-in Gitea identity' (#1463) from timmy/1462-principal-scoped-security-activity into main
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
2026-08-27 08:22:10 +00:00
timmy 3b9f184a47 security: scope activity journal by principal (Closes #1462)
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 08:10:27 +00:00
timmy bf716cab6e Merge pull request 'Personalize routine priority in adaptive mobile Queues' (#1461) from timmy/1460-personalized-mobile-queue-priority into main
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m34s
CI / release-candidate (push) Successful in 6s
2026-08-27 07:21:38 +00:00
20 changed files with 771 additions and 94 deletions

No files matched your search

+8
View File
@@ -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
+3
View File
@@ -1525,6 +1525,9 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
+11 -1
View File
@@ -433,17 +433,25 @@
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(),
onChange: () => {
renderMobileQueuePresentation();
void mobileQueuePriority.sync();
},
});
mobileQueuePriority.start();
window.addEventListener('online', () => { void mobileQueuePriority.sync(); });
let rR = null;
function rRC() {
if (rR) return rR;
@@ -676,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');
@@ -5638,6 +5647,7 @@
confirmedOwnerLogin = activeFlushLogin;
mobileQueuePriority.render();
renderMobileQueuePresentation();
void mobileQueuePriority.load();
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
+5
View File
@@ -2191,6 +2191,11 @@
<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>
+180 -32
View File
@@ -7,9 +7,12 @@
];
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();
@@ -19,32 +22,77 @@
function valid(order) {
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
new Set(order).size === DEFAULT_ORDER.length &&
order.every((name, index) => DEFAULT_ORDER.includes(name) && typeof name === 'string');
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
}
function getOrder() {
function fresh() {
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
}
function read() {
const accountKey = key();
if (!accountKey || !storage) return DEFAULT_ORDER.slice();
if (!accountKey || !storage) return fresh();
if (memory?.key === accountKey) return memory.value;
let value = fresh();
try {
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
return valid(saved) ? saved.slice() : DEFAULT_ORDER.slice();
} catch (_error) {
return DEFAULT_ORDER.slice();
}
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 save(order) {
function persist(value) {
const accountKey = key();
if (!accountKey || !storage || !valid(order)) return false;
if (!accountKey || !storage) return false;
memory = {key:accountKey, value};
try {
storage.setItem(accountKey, JSON.stringify(order));
options.onChange?.(order.slice());
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);
@@ -56,15 +104,120 @@
}
function reset() {
const accountKey = key();
if (accountKey && storage) {
try { storage.removeItem(accountKey); } catch (_error) {}
}
const order = DEFAULT_ORDER.slice();
options.onChange?.(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);
}
@@ -82,42 +235,37 @@
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.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);
if (options.status) options.status.textContent = displayName(name) + ' moved earlier.';
render();
move(name, -1); render();
});
const later = documentRef.createElement('button');
later.textContent = 'Later';
later.setAttribute('type', '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);
if (options.status) options.status.textContent = displayName(name) + ' moved later.';
render();
move(name, 1); render();
});
controls.append(earlier, later);
row.append(label, controls);
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();
if (options.status) options.status.textContent = 'Routine order reset.';
render();
reset(); render();
});
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
return render();
}
return {getOrder, move, reset, render, start, defaultOrder: () => DEFAULT_ORDER.slice()};
return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});
+3
View File
@@ -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",
+88 -3
View File
@@ -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",
)
@@ -4068,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:
@@ -6641,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:
@@ -7096,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:
@@ -8001,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:
@@ -8092,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:
@@ -8279,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(
@@ -8377,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:
+100
View File
@@ -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
View File
@@ -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:
+14 -1
View File
@@ -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)
@@ -215,7 +215,7 @@ def test_operator_reorders_routine_queues_without_leaking_priority_between_accou
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("Following moved earlier.")
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()))
+4 -3
View File
@@ -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()
+2 -2
View File
@@ -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
+143 -1
View File
@@ -89,6 +89,133 @@ process.stdout.write(JSON.stringify({{
}
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))});
@@ -127,7 +254,7 @@ process.stdout.write(JSON.stringify({{
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
"earlierLabel": "Move Following earlier",
"laterLabel": "Move Following later",
"status": "Following moved earlier.",
"status": "Sync pending.",
}
@@ -399,6 +526,21 @@ async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe()
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()
+3 -1
View File
@@ -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"),
+5 -5
View File
@@ -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"
+75
View File
@@ -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"
+27
View File
@@ -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
+31 -4
View File
@@ -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"
+42 -30
View File
@@ -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 == []