Compare commits

...
Author SHA1 Message Date
timmy 0ac1277479 perf: keep session validation off event loop (#275)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 07:15:37 +00:00
rockachopa 6185f6a7ff Merge pull request 'Surface deadline-critical work in mobile Attention' (#274) from timmy/273-mobile-deadline-attention into main
CI / lint (push) Successful in 26s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-08 06:55:34 +00:00
timmy 98d6a29fa0 feat: surface deadline-critical work in Attention (#273)
CI / lint (pull_request) Successful in 27s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 06:54:18 +00:00
timmy 3decdb4cac Merge pull request 'Keep sign-in retry guidance within the mobile viewport' (#272)
CI / lint (push) Successful in 29s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #270
2026-08-08 06:27:17 +00:00
timmy 0fa3e675dc fix: keep sign-in retry state within mobile viewport (#270)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 6s
2026-08-08 06:26:13 +00:00
timmy 3210b8be6d Merge pull request 'Throttle repeated operator sign-in failures' (#271)
CI / lint (push) Successful in 26s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #270
2026-08-08 06:20:25 +00:00
timmy 87becb08ad feat: throttle repeated operator sign-in failures (#270)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 7s
2026-08-08 06:19:08 +00:00
timmy 334fe77e84 Revoke captured operator sessions on logout (#269)
CI / lint (push) Successful in 25s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #268
2026-08-08 05:57:06 +00:00
timmy c8de28ca0c feat: revoke operator sessions on logout (#268)
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 05:55:51 +00:00
timmy 741d768fc3 Merge pull request 'Resume queued delivery after operator sign-in' (#267) from timmy/266-resume-queued-delivery into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 05:34:02 +00:00
timmy acd2d82d10 fix: resume queued delivery after sign-in (#266)
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 05:32:29 +00:00
timmy 65d1f9f194 Confirm offline work only after durable background admission (#265)
CI / lint (push) Successful in 25s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #264
2026-08-08 05:11:20 +00:00
timmy c74c63d29c feat: make background queue admission durable (#264)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 05:10:11 +00:00
24 changed files with 1274 additions and 52 deletions

No files matched your search

+31
View File
@@ -59,11 +59,38 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Sign-in failures are scoped to a hashed canonical client address and persisted across
workers and restarts. Once the budget is exhausted, the server returns `429` with
`Retry-After`; the mobile login form disables retries for that interval. Expired
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
that setting, a reverse proxy is safely treated as one shared source.
Each signed cookie includes an opaque session identifier whose hash and expiry are
kept in the SQLite session registry. Keep that registry on persistent, writable
storage shared by all dashboard workers. Sign-out revokes only the current session
before clearing browser state, so a copied cookie cannot be replayed afterward;
other signed-in devices remain active. Deploying this version invalidates older
cookies that do not contain a registered identifier, so operators must sign in once
again. Registry read or write failures return a sanitized HTTP 503 before Gitea is
contacted.
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
@@ -152,6 +179,10 @@ work finishes**; permission is requested only from that user gesture and the cho
is stored for the confirmed account in the private background outbox database.
Successful deliveries produce privacy-safe receipts that open the created issue or
source conversation, while permanent validation failures open Drafts for recovery.
Authentication expiry during a worker mutation is recoverable: the worker releases the
claim without creating an Attention receipt, stops that drain, and keeps the original
idempotency key. Loading the authenticated dashboard after signing in asks the worker
to resume queued delivery automatically.
Notification text never includes issue titles, comment bodies, or validation details.
The preference is off by default, unsupported or denied browsers retain foreground
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
+19 -5
View File
@@ -14,17 +14,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
} catch (_error) { return []; }
}
function write(items) {
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
if (backgroundSync?.reconcile) {
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(message) {
function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
@@ -47,10 +47,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
queuedAt: Number(now()),
};
items.push(item);
write(items);
write(items, mirror);
return item;
}
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function update(id, changes) {
let updated = null;
write(read().map(item => {
@@ -182,7 +196,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+4
View File
@@ -224,6 +224,10 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
} catch (error) {
const status = Number(error?.status || 0);
if (status === 401) {
await store.release(item.id);
throw error;
}
if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
return { attention: true, error, receipt: receiptFor(item, 'attention') };
+32 -11
View File
@@ -933,9 +933,10 @@ textarea { resize: vertical; min-height: 120px; }
store: backgroundIssueStore, fetchJson: fetchReviewJson,
});
backgroundIssueSync.requestSync = async () => {
if (!('serviceWorker' in navigator)) return;
if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable');
const registration = await navigator.serviceWorker.ready;
if (registration.sync) await registration.sync.register('stackchain-issue-outbox-v1');
if (!registration.sync) throw new Error('Background Sync unavailable');
await registration.sync.register('stackchain-issue-outbox-v1');
};
}
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
@@ -2819,10 +2820,18 @@ textarea { resize: vertical; min-height: 120px; }
}
const button = qs('#submit-new-issue');
button.disabled = true;
qs('#create-issue-status').textContent = navigator.onLine ? 'Sending issue…' : 'Saving issue to outbox…';
qs('#create-issue-status').textContent = 'Saving for background delivery…';
try {
const queued = editingOutboxId ? issueOutbox.update(editingOutboxId, captureDraft) :
issueOutbox.enqueue(captureDraft);
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) :
await issueOutbox.enqueueDurably(captureDraft);
const queued = admission.item;
if (!admission.background) {
editingOutboxId = queued.id;
refreshMyWorkView();
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
button.disabled = false;
return;
}
editingOutboxId = null;
issueCapture.clearDraft();
closeCreateIssueSheet();
@@ -3038,11 +3047,17 @@ textarea { resize: vertical; min-height: 120px; }
} catch (error) {
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
authoredOutbox.enqueue({ kind:'issue-comment', repository:selectedIssue.repository,
qs('#issue-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body, operationId });
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
refreshMyWorkView();
if (admission.background) {
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
qs('#issue-comment').focus();
}
} else {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
@@ -3139,11 +3154,17 @@ textarea { resize: vertical; min-height: 120px; }
} catch (error) {
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation');
authoredOutbox.enqueue({ kind:'pull-comment', repository:selectedPull.repository,
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'pull-comment', repository:selectedPull.repository,
number:selectedPull.number, body, operationId });
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
refreshMyWorkView();
if (admission.background) {
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#pull-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
qs('#pull-comment').focus();
}
} else {
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#pull-comment').focus();
+35 -7
View File
@@ -13,17 +13,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
} catch (_error) { return []; }
}
function write(items) {
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('issue');
if (backgroundSync?.reconcile) {
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(draft) {
function enqueue(draft, mirror = true) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
@@ -45,11 +45,25 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
items.push(item);
write(items);
write(items, mirror);
return item;
}
function update(id, draft) {
async function enqueueDurably(draft) {
const item = enqueue(draft, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function update(id, draft, mirror = true) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
@@ -62,10 +76,24 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
};
delete updated.error;
return updated;
}));
}), mirror);
return updated;
}
async function updateDurably(id, draft) {
const item = update(id, draft, false);
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
@@ -170,7 +198,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+76
View File
@@ -0,0 +1,76 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createLoginController = factory;
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
const form = options.form;
const status = options.status;
const button = options.button;
const fetchImpl = options.fetchImpl;
const location = options.location;
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null;
function showRetryCountdown(seconds) {
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
button.disabled = true;
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
timer = setIntervalImpl(() => {
remaining -= 1;
if (remaining <= 0) {
clearIntervalImpl(timer);
timer = null;
button.disabled = false;
status.textContent = 'You can try signing in again.';
return;
}
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
}, 1000);
}
async function submit(accessToken) {
status.textContent = 'Signing in…';
let response;
try {
response = await fetchImpl('api/v1/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken }),
});
} catch (_error) {
form.reset();
status.textContent = 'Sign-in failed. Check your connection and try again.';
return;
}
form.reset();
if (response.ok) {
location.replace('./');
return;
}
if (response.status === 429) {
showRetryCountdown(response.headers.get('Retry-After'));
return;
}
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const controller = createLoginController({
form,
status,
button,
fetchImpl: fetch.bind(window),
location: window.location,
});
form.addEventListener('submit', event => {
event.preventDefault();
const accessToken = new FormData(form).get('access_token');
controller.submit(accessToken);
});
}
+41 -7
View File
@@ -14,6 +14,25 @@ function issueDueState(dueDate, now) {
};
}
function urgencyReason(item) {
if (!item?.is_assigned || item.kind !== 'issue') return '';
const priorityLabels = ['p0', 'priority-high', 'critical'];
const priorityLabel = (item.labels || []).find(label =>
priorityLabels.includes(String(label).toLowerCase())
);
if (priorityLabel) return priorityLabel + ' priority';
return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : '';
}
function attentionReason(item) {
if (item?.has_update) return 'Unread update';
return urgencyReason(item) || (item?.is_review ? 'Needs your review' : '');
}
function needsAttention(item) {
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
@@ -28,7 +47,7 @@ function buildMyWork(data, now = new Date()) {
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
return {
const normalized = {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
@@ -41,6 +60,9 @@ function buildMyWork(data, now = new Date()) {
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
};
normalized.needs_attention = needsAttention(normalized);
normalized.attention_reason = attentionReason(normalized);
return normalized;
});
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
@@ -50,6 +72,8 @@ function buildMyWork(data, now = new Date()) {
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
existing.has_update = true;
existing.needs_attention = true;
existing.attention_reason = 'Unread update';
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
@@ -64,6 +88,8 @@ function buildMyWork(data, now = new Date()) {
is_review: false,
is_assigned: false,
has_update: true,
needs_attention: true,
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
@@ -84,7 +110,10 @@ function acknowledgeNotification(items, notificationId) {
if (item.notification_id !== notificationId) return [item];
if (item.kind === 'update') return [];
const { notification_id, ...acknowledged } = item;
return [{ ...acknowledged, has_update: false }];
const next = { ...acknowledged, has_update: false };
if ('needs_attention' in item) next.needs_attention = needsAttention(next);
if ('attention_reason' in item) next.attention_reason = attentionReason(next);
return [next];
});
}
@@ -383,11 +412,16 @@ function createNotificationReplier({
} catch (error) {
const status = Number(error?.status || 0);
if (authoredOutbox && (!status || status >= 500)) {
authoredOutbox.enqueue({
onStatus('Saving for background delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
});
onStatus('Queued for sync when the connection returns.');
return { queued: true };
if (admission.background) {
onStatus('Queued for sync when the connection returns.');
return { queued: true };
}
onStatus('Saved for next launch; background delivery unavailable.');
return { queued: false, degraded: true };
}
onStatus('Could not send reply. Your draft is safe; retry.');
return false;
@@ -400,7 +434,7 @@ function createNotificationReplier({
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
@@ -560,7 +594,7 @@ function summarizeMyWork(items) {
function countMyWork(items) {
return {
all: items.length,
attention: items.filter((item) => item.has_update || item.is_review).length,
attention: items.filter(needsAttention).length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
+5 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v17';
const CACHE = 'stackchain-dashboard-shell-v19';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -96,6 +96,10 @@ self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
});
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const route = String(event.notification.data?.route || '');
+11 -2
View File
@@ -12,6 +12,7 @@
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
location: root.location,
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
});
@@ -19,13 +20,14 @@
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
boundary.resumeQueuedWork();
};
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach();
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
onExpired = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -88,5 +90,12 @@
}
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
async function resumeQueuedWork() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
});
+39 -7
View File
@@ -1,5 +1,6 @@
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
import asyncio
import base64
import hashlib
import hmac
@@ -12,6 +13,8 @@ from dataclasses import dataclass
from fastapi import Request
from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
@@ -22,6 +25,7 @@ INSECURE_LOCAL_MODE = "insecure-local"
@dataclass(frozen=True)
class Session:
session_id: str
csrf: str
expires_at: int
@@ -76,17 +80,31 @@ def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def _session_store(now: int | None = None) -> SessionStore:
database = os.getenv(
"STACKCHAIN_SESSION_DB",
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
)
current = time.time if now is None else lambda: now
return SessionStore(database, clock=current)
def issue_session(now: int | None = None) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl))
session = Session(
session_id=secrets.token_urlsafe(32),
csrf=secrets.token_urlsafe(24),
expires_at=issued_at + max(1, ttl),
)
payload = json.dumps(
{"csrf": session.csrf, "exp": session.expires_at},
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
separators=(",", ":"),
sort_keys=True,
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(session.session_id, session.expires_at)
return f"{encoded}.{signature}", session
@@ -99,17 +117,31 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
return None
try:
payload = json.loads(_decode(encoded))
session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"]))
session = Session(
session_id=payload["sid"],
csrf=payload["csrf"],
expires_at=int(payload["exp"]),
)
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return None
current = int(time.time() if now is None else now)
if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf:
if (
session.expires_at <= current
or not isinstance(session.session_id, str)
or not session.session_id
or not isinstance(session.csrf, str)
or not session.csrf
):
return None
return session
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
def request_session(request: Request) -> Session | None:
return verify_session(request.cookies.get(SESSION_COOKIE))
def revoke_session(session: Session) -> None:
_session_store().revoke(session.session_id)
async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
def cookie_path(request: Request) -> str:
+149
View File
@@ -0,0 +1,149 @@
"""Durable throttling state for operator sign-in attempts."""
import hashlib
import ipaddress
import math
import sqlite3
from pathlib import Path
from typing import Callable
class LoginAttemptStoreError(RuntimeError):
"""Raised when sign-in throttling state cannot be accessed safely."""
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
try:
peer = ipaddress.ip_address(peer_host)
trusted = [
ipaddress.ip_network(value.strip())
for value in trusted_proxy_cidrs.split(",")
if value.strip()
]
except ValueError:
return peer_host
if not any(peer in network for network in trusted):
return str(peer)
try:
forwarded = [
ipaddress.ip_address(value.strip())
for value in forwarded_for.split(",")
if value.strip()
]
except ValueError:
return str(peer)
for address in reversed(forwarded):
if not any(address in network for network in trusted):
return str(address)
return str(forwarded[0]) if forwarded else str(peer)
class LoginAttemptStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_failures: int,
window_seconds: int,
max_entries: int = 10_000,
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_failures = max(1, max_failures)
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _digest(source: str) -> str:
return hashlib.sha256(source.encode()).hexdigest()
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_attempts (
source_hash TEXT PRIMARY KEY,
failures INTEGER NOT NULL,
window_started_at REAL NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def retry_after(self, source: str) -> int:
now = self.clock()
try:
with self._connect() as connection:
row = connection.execute(
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
(self._digest(source),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
if row is None or row[0] < self.max_failures:
return 0
remaining = row[1] + self.window_seconds - now
return max(0, math.ceil(remaining))
def record_failure(self, source: str) -> None:
now = self.clock()
source_hash = self._digest(source)
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
(self.window_seconds, now),
)
row = connection.execute(
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
(source_hash,),
).fetchone()
if row is None or row[1] + self.window_seconds <= now:
connection.execute(
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
(source_hash, now),
)
else:
connection.execute(
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
(source_hash,),
)
connection.execute(
"""
DELETE FROM login_attempts
WHERE source_hash NOT IN (
SELECT source_hash FROM login_attempts
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_entries,),
)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def clear(self, source: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM login_attempts WHERE source_hash = ?",
(self._digest(source),),
)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
+83 -3
View File
@@ -28,6 +28,7 @@ from src.gitea_proxy import (
repos,
)
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -129,6 +130,20 @@ class DashboardSignIn(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024)
def _login_attempt_store() -> LoginAttemptStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
return LoginAttemptStore(
os.getenv(
"STACKCHAIN_LOGIN_ATTEMPT_DB",
os.path.join(state_dir, "login-attempts.sqlite3"),
),
clock=time.time,
max_failures=int(os.getenv("STACKCHAIN_LOGIN_MAX_FAILURES", "5")),
window_seconds=int(os.getenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "300")),
max_entries=int(os.getenv("STACKCHAIN_LOGIN_MAX_ENTRIES", "10000")),
)
class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
@@ -455,7 +470,16 @@ async def require_operator_session(request: Request, call_next):
or path.startswith("/static/")
or (path == "/api/v1/session" and request.method == "POST")
)
session = dashboard_auth.request_session(request)
session = None
if not public:
try:
session = await dashboard_auth.request_session(request)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if not public and session is None:
if path.startswith("/api/"):
return JSONResponse(
@@ -514,12 +538,59 @@ def health() -> dict[str, str]:
@app.post("/api/v1/session")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
peer_host = request.client.host if request.client is not None else "unknown"
source = client_source(
peer_host,
request.headers.get("x-forwarded-for", ""),
os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""),
)
attempts = _login_attempt_store()
try:
retry_after = attempts.retry_after(source)
except LoginAttemptStoreError:
return JSONResponse(
{"detail": "Sign-in throttling is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if retry_after:
return JSONResponse(
{"detail": "Too many sign-in attempts"},
status_code=429,
headers={
"Cache-Control": "no-store",
"Retry-After": str(retry_after),
},
)
configured_token = dashboard_auth.access_token()
if not configured_token or not hmac.compare_digest(
payload.access_token, configured_token
):
try:
attempts.record_failure(source)
except LoginAttemptStoreError:
return JSONResponse(
{"detail": "Sign-in throttling is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
raise HTTPException(status_code=401, detail="Invalid access token")
signed, session = dashboard_auth.issue_session()
try:
attempts.clear(source)
except LoginAttemptStoreError:
return JSONResponse(
{"detail": "Sign-in throttling is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
try:
signed, session = dashboard_auth.issue_session()
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie(
@@ -546,7 +617,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
@app.get("/api/v1/session")
async def session_status(request: Request):
session = dashboard_auth.request_session(request)
session = request.state.dashboard_session
return {
"authenticated": session is not None,
"csrf_token": session.csrf if session is not None else "",
@@ -555,6 +626,15 @@ async def session_status(request: Request):
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
try:
dashboard_auth.revoke_session(session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
+88
View File
@@ -0,0 +1,88 @@
"""Durable active-session registry used to revoke signed operator sessions."""
import hashlib
import sqlite3
from pathlib import Path
from typing import Callable
class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely."""
class SessionStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _digest(session_id: str) -> str:
return hashlib.sha256(session_id.encode()).hexdigest()
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
try:
if initialize:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(
self.path, timeout=self.lock_timeout_seconds
)
else:
connection = sqlite3.connect(
f"{self.path.resolve().as_uri()}?mode=rw",
timeout=self.lock_timeout_seconds,
uri=True,
)
if initialize:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS active_sessions (
session_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def activate(self, session_id: str, expires_at: int) -> None:
try:
with self._connect(initialize=True) as connection:
connection.execute(
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
)
connection.execute(
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
(self._digest(session_id), expires_at),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def is_active(self, session_id: str, expires_at: int) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
row = connection.execute(
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return row is not None and row[0] == expires_at and expires_at > now
def revoke(self, session_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+3 -3
View File
@@ -11,10 +11,10 @@ SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
<script src="static/login.js"></script></body></html>"""
class RevalidatingHTMLResponse(HTMLResponse):
+32 -1
View File
@@ -182,6 +182,35 @@ setTimeout(() => {{
]
def test_authored_outbox_waits_for_durable_background_admission():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let release; const gate = new Promise(resolve => release = resolve); const events = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync: {{
reconcile: async (_items, lane) => {{ events.push('mirror:' + lane); await gate; events.push('committed'); }},
requestSync: async () => events.push('registered'),
}},
}});
const admission = outbox.enqueueDurably({{
kind:'issue-comment',repository:'o/r',number:1,body:'Do not lose',operationId:'message-1'
}}).then(result => {{ events.push('confirmed'); return result; }});
Promise.resolve().then(async () => {{
const pending = events.slice(); release(); const result = await admission;
process.stdout.write(JSON.stringify({{pending,events,result,items:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["pending"] == ["mirror:authored"]
assert output["events"] == ["mirror:authored", "committed", "registered", "confirmed"]
assert output["result"]["durability"] == "background"
assert output["items"][0]["operationId"] == "message-1"
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -217,7 +246,9 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
assert "const authoredOutbox = createAuthoredOutbox({" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushAuthoredOutbox();" in html
assert "authoredOutbox.enqueue" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "authoredOutbox.discard(item.outbox_id)" in html
assert "if (result?.queued)" in html
+38
View File
@@ -260,6 +260,44 @@ const fetchJson = async url => {{
assert output["result"]["blocked"] == 1
def test_session_expiry_during_delivery_releases_claim_without_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const queued = [
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
];
const state = {{released:[],failed:[],mutationKeys:[]}};
const store = {{
claimNext: async () => queued.shift() || null,
release: async id => state.released.push(id),
fail: async (id,message) => state.failed.push({{id,message}}),
countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
state.mutationKeys.push(options.headers['Idempotency-Key']);
const error = new Error('Authentication required'); error.status = 401; throw error;
}};
(async () => {{
let error = null;
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output == {
"state": {
"released": ["capture-auth"],
"failed": [],
"mutationKeys": ["stable-key"],
},
"error": {"message": "Authentication required", "status": 401},
}
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+242 -1
View File
@@ -1,14 +1,22 @@
import asyncio
import time
import httpx
import pytest
from src import main
from src.session_store import SessionStoreError
@pytest.fixture
def access_control(monkeypatch):
def access_control(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-attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
@pytest.mark.anyio
@@ -109,6 +117,43 @@ async def test_sign_in_creates_secure_session_without_echoing_access_token(acces
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
failures = [
await client.post("/api/v1/session", json={"access_token": "wrong"})
for _ in range(3)
]
blocked = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert [response.status_code for response in failures] == [401, 401, 401]
assert blocked.status_code == 429
assert blocked.json() == {"detail": "Too many sign-in attempts"}
assert blocked.headers["retry-after"].isdigit()
assert blocked.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_successful_sign_in_clears_prior_failures(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
for _ in range(2):
await client.post("/api/v1/session", json={"access_token": "wrong"})
success = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
after_success = [
await client.post("/api/v1/session", json={"access_token": "wrong"})
for _ in range(3)
]
assert success.status_code == 200
assert [response.status_code for response in after_success] == [401, 401, 401]
@pytest.mark.anyio
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
async def user():
@@ -133,6 +178,30 @@ async def test_authenticated_get_reaches_private_api(access_control, monkeypatch
assert response.json()["user"]["login"] == "timmy"
@pytest.mark.anyio
async def test_session_status_reuses_the_middleware_validation(access_control, monkeypatch):
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"}
)
original_store = main.dashboard_auth._session_store()
lookups = 0
class CountingStore:
def is_active(self, session_id, expires_at):
nonlocal lookups
lookups += 1
return original_store.is_active(session_id, expires_at)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json()["authenticated"] is True
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -207,6 +276,178 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
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"}
)
captured = client.cookies["stackchain_session"]
csrf = client.cookies["stackchain_csrf"]
async with httpx.AsyncClient(
transport=transport,
base_url="https://test",
headers={"Cookie": f"stackchain_session={captured}"},
) as replay:
before = await replay.get("/api/v1/background-identity")
await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
async with httpx.AsyncClient(
transport=transport,
base_url="https://test",
headers={"Cookie": f"stackchain_session={captured}"},
) as replay:
after = await replay.get("/api/v1/background-identity")
assert before.status_code == 200
assert after.status_code == 401
assert after.headers["cache-control"] == "no-store"
assert calls == 1
@pytest.mark.anyio
async def test_session_registry_latency_does_not_block_the_event_loop(access_control, monkeypatch):
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"}
)
class SlowStore:
def is_active(self, session_id, expires_at):
time.sleep(0.15)
return True
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.01)
heartbeat_elapsed = time.perf_counter() - started
response = await private_request
assert response.status_code == 200
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_session_registry_read_failure_fails_closed_before_gitea(
access_control, monkeypatch
):
called = False
async def user():
nonlocal called
called = True
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
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"}
)
class BrokenStore:
def is_active(self, session_id, expires_at):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.get("/api/v1/background-identity")
health = await client.get("/healthz")
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert "database path" not in response.text
assert called is False
assert health.status_code == 200
@pytest.mark.anyio
async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch):
class BrokenStore:
def activate(self, session_id, expires_at):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_logout_registry_failure_does_not_claim_revocation(access_control, monkeypatch):
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"}
)
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_public_routes_skip_session_registry_validation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class BrokenStore:
def is_active(self, session_id, expires_at):
raise SessionStoreError("public routes must not read the registry")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
login = await client.get("/login")
manifest = await client.get("/manifest.webmanifest")
static = await client.get("/static/session.js")
assert signed_in.status_code == 200
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
@pytest.mark.anyio
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
async def user():
+13 -1
View File
@@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -33,6 +33,7 @@ const boundary = createSessionBoundary({{
sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
@@ -72,6 +73,17 @@ process.stdout.write(JSON.stringify(state));
assert result["assigned"] == "/dashboard/login"
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
result = run_session_scenario(
"""
await boundary.resumeQueuedWork();
process.stdout.write(JSON.stringify(state));
"""
)
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
@pytest.mark.anyio
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
html = await dashboard()
+68 -1
View File
@@ -186,6 +186,70 @@ setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
assert output["syncs"] == 1
def test_issue_outbox_confirms_durable_admission_only_after_mirror_and_sync():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let releaseMirror; let releaseSync;
const mirrorGate = new Promise(resolve => releaseMirror = resolve);
const syncGate = new Promise(resolve => releaseSync = resolve);
const events = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-1',
backgroundSync: {{
reconcile: async items => {{ events.push('mirror-start'); await mirrorGate; events.push('mirror-committed'); }},
requestSync: async () => {{ events.push('sync-start'); await syncGate; events.push('sync-registered'); }},
}},
}});
const admission = outbox.enqueueDurably({{repository:'stackchain/api',title:'Keep this',body:'Draft'}})
.then(result => {{ events.push('confirmed'); return result; }});
Promise.resolve().then(async () => {{
const beforeMirror = events.slice();
releaseMirror(); await new Promise(resolve => setTimeout(resolve, 0));
const beforeSync = events.slice();
releaseSync();
const result = await admission;
process.stdout.write(JSON.stringify({{beforeMirror,beforeSync,events,result,items:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["beforeMirror"] == ["mirror-start"]
assert output["beforeSync"] == ["mirror-start", "mirror-committed", "sync-start"]
assert output["events"][-1] == "confirmed"
assert output["result"]["background"] is True
assert output["result"]["item"]["id"] == "durable-1"
assert output["items"][0]["title"] == "Keep this"
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'foreground-1',
backgroundSync: {{
reconcile: async () => {{}},
requestSync: async () => {{ throw new Error('Background Sync unavailable'); }},
}},
}});
outbox.enqueueDurably({{repository:'stackchain/api',title:'Retain me',body:'Draft'}}).then(result =>
process.stdout.write(JSON.stringify({{
background:result.background, durability:result.durability,
error:result.error.message, item:result.item, persisted:outbox.list()
}}))
);
"""
output = run_node(script)
assert output["background"] is False
assert output["durability"] == "foreground-only"
assert output["error"] == "Background Sync unavailable"
assert output["persisted"] == [output["item"]]
def test_foreground_delivery_uses_same_atomic_background_claim():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -263,7 +327,10 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
assert '<script src="static/issue-outbox.js"></script>' in html
assert "const issueOutbox = createIssueOutbox({" in html
assert "issueOutbox.enqueue(captureDraft)" in html
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "throw new Error('Background Sync unavailable')" in html
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
assert "navigator.onLine" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
+74
View File
@@ -0,0 +1,74 @@
import sqlite3
from src.login_attempt_store import LoginAttemptStore, client_source
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
trusted = "127.0.0.0/8, 10.0.0.0/8"
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
assert (
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
== "203.0.113.7"
)
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
first = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
second = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
assert first.retry_after("203.0.113.7") == 0
first.record_failure("203.0.113.7")
second.record_failure("203.0.113.7")
first.record_failure("203.0.113.7")
assert second.retry_after("203.0.113.7") == 60
now[0] = 1_060.0
assert first.retry_after("203.0.113.7") == 0
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
store.record_failure("203.0.113.1")
store.record_failure("203.0.113.2")
now[0] = 1_060.0
store.record_failure("203.0.113.3")
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: 1_000.0,
max_failures=3,
window_seconds=60,
max_entries=2,
)
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
store.record_failure(address)
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
+60
View File
@@ -0,0 +1,60 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import login
ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js"
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ reset: 0, interval: null }};
const status = {{ textContent: '' }};
const button = {{ disabled: false }};
const form = {{ reset: () => state.reset += 1 }};
const controller = createLoginController({{
form, status, button,
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
location: {{ replace: () => {{}} }},
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
clearIntervalImpl: () => {{}},
}});
(async () => {{
await controller.submit('never-store-this-token');
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
state.interval();
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
state.interval();
state.finished = {{ disabled: button.disabled, status: status.textContent }};
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["initial"] == {
"disabled": True,
"status": "Too many attempts. Try again in 2 seconds.",
"reset": 1,
}
assert state["afterTick"]["disabled"] is True
assert state["finished"] == {
"disabled": False,
"status": "You can try signing in again.",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html
+52
View File
@@ -313,6 +313,58 @@ process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason
]
def test_attention_includes_only_assigned_deadline_and_priority_critical_work():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Assigned P0", "repository": "stackchain/api",
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
{"number": 2, "title": "Assigned overdue", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
{"number": 3, "title": "Assigned due today", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
{"number": 4, "title": "Assigned future", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
{"number": 5, "title": "Unassigned P0", "repository": "stackchain/api",
"labels": ["critical"], "assignees": [], "due_date": "2026-08-10T23:59:59Z"},
{"number": 6, "title": "Unassigned overdue", "repository": "stackchain/api",
"labels": [], "assignees": [], "due_date": "2026-08-06T23:59:59Z"},
],
"pull_requests": [
{"number": 7, "title": "Updated review", "repository": "stackchain/web",
"labels": [], "assignees": [], "work_reasons": ["review_requested"]},
],
"notifications": [
{"id": 70, "number": 7, "repository": "stackchain/web", "unread": True,
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/7"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
const attention = buildMyWork.filterMyWork(items, 'attention');
process.stdout.write(JSON.stringify({{
titles: attention.map(item => item.title),
count: buildMyWork.countMyWork(items).attention,
reasons: Object.fromEntries(attention.map(item => [item.title, item.attention_reason])),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"titles": ["Assigned P0", "Updated review", "Assigned overdue", "Assigned due today"],
"count": 4,
"reasons": {
"Assigned P0": "P0 priority",
"Updated review": "Unread update",
"Assigned overdue": "Overdue",
"Assigned due today": "Due today",
},
}
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
payload = {
"user": {"login": "timmy"},
+18 -2
View File
@@ -68,6 +68,11 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchMessage(data) {{
let pending;
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
let pending;
listeners.notificationclick({{
@@ -86,10 +91,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v17" in source
assert "stackchain-dashboard-shell-v19" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@@ -104,6 +109,17 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
assert result["backgroundFlushes"] == 1
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-resume-outbox'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["backgroundFlushes"] == 1
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario(
"""
+61
View File
@@ -0,0 +1,61 @@
import sqlite3
import pytest
from src import session_store
from src.session_store import SessionStore, SessionStoreError
def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
now = [1_000.0]
database = tmp_path / "sessions.sqlite3"
first = SessionStore(database, clock=lambda: now[0])
first.activate("first-session-secret", 2_000)
first.activate("second-session-secret", 2_000)
reconstructed = SessionStore(database, clock=lambda: now[0])
reconstructed.revoke("first-session-secret")
assert reconstructed.is_active("first-session-secret", 2_000) is False
assert reconstructed.is_active("second-session-secret", 2_000) is True
assert b"first-session-secret" not in database.read_bytes()
assert b"second-session-secret" not in database.read_bytes()
def test_validation_does_not_create_a_missing_registry(tmp_path):
database = tmp_path / "sessions.sqlite3"
store = SessionStore(database, clock=lambda: 1_000.0)
with pytest.raises(SessionStoreError):
store.is_active("unknown-session", 2_000)
assert database.exists() is False
def test_validation_executes_only_a_read_query(tmp_path, monkeypatch):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("active-session", 2_000)
statements = []
connect = sqlite3.connect
def traced_connect(*args, **kwargs):
connection = connect(*args, **kwargs)
connection.set_trace_callback(statements.append)
return connection
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
assert store.is_active("active-session", 2_000) is True
assert [statement.split()[0].upper() for statement in statements] == ["SELECT"]
def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("expiring-session", 1_001)
now[0] = 1_001.0
assert store.is_active("expiring-session", 1_001) is False
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)