Compare commits

...
Author SHA1 Message Date
timmy 94aad3e76e Merge pull request 'Fail closed when dashboard authentication is not explicitly configured' (#263) from timmy/262-fail-closed-dashboard-auth into main
CI / lint (push) Successful in 25s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #262
2026-08-08 04:33:25 +00:00
timmy 00a956b1fb feat: fail closed on invalid dashboard auth (#262)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 04:32:15 +00:00
rockachopa 7bdfe62495 Merge pull request 'Notify operators when background queued work finishes' (#261) from timmy/260-background-delivery-receipts into main
CI / lint (push) Successful in 30s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
2026-08-08 04:19:47 +00:00
timmy 1b10a5a8a8 feat: notify on background delivery completion (#260)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 04:18:32 +00:00
timmy a7afd85634 Merge pull request 'Require operator sessions for privileged dashboard access' (#259) from timmy/258-operator-session into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:42:53 +00:00
timmy 39357263d0 feat: require operator sessions for privileged access (#258)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:41:30 +00:00
rockachopa e40c75b360 Merge pull request 'Defer from mobile detail sheets and continue triage' (#257) from timmy/256-mobile-detail-defer into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:17:03 +00:00
timmy fdb39e43ad feat: defer from mobile detail sheets (#256)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:16:01 +00:00
rockachopa 61168dbfd4 Defer mobile Attention items into a Later queue (#255)
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #254
2026-08-08 02:42:22 +00:00
15 changed files with 1206 additions and 38 deletions

No files matched your search

+37 -7
View File
@@ -44,19 +44,41 @@ head-scoped draft comments anchored to changed lines; the dashboard validates ea
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard
only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Then start the API and bundled frontend:
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
`operator` mode. It requires two independent secrets of at least 24 characters;
missing, short, reused, or invalid configuration leaves `/healthz` available but
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
the browser receives only a short-lived signed session and CSRF proof. Generate
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
them at runtime—never commit them:
```bash
export GITEA_URL='https://forge.example.com'
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 eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
Gitea connection directly, request
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
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
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
the injected value and restarting the service; changing the signing secret expires
all sessions immediately. The signing key is never derived from the access token.
For direct, unproxied local development only, set
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
clients; do not use it behind a reverse proxy, whose loopback connection would hide
the browser's peer address. To verify the backend and its Gitea connection in
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced,
@@ -125,7 +147,15 @@ and registered with Background Sync, so a supporting installed browser can deliv
new issues, issue comments, pull-request comments, and unread-update replies after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Browsers without
idempotency key. Installed browsers can explicitly enable **Notify me when queued
work finishes**; permission is requested only from that user gesture and the choice
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.
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.
Browsers without
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
queued and needs-attention messages with explicit send/discard controls; reopening the
dashboard reconciles worker completions and permanent failures into the visible outbox.
+9
View File
@@ -0,0 +1,9 @@
import pytest
@pytest.fixture(autouse=True)
def explicit_local_dashboard_auth_mode(monkeypatch):
"""Keep tests intentional now that deployed authentication fails closed."""
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
+52 -6
View File
@@ -48,6 +48,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const existing = await records.getAll();
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
for (const current of existing) {
if (current.recordType === 'receipt-preference') continue;
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
@@ -112,6 +113,27 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
function preferenceId(ownerLogin) {
return 'receipt-preference:' + String(ownerLogin || '').trim();
}
async function setReceiptPreference(ownerLogin, enabled) {
const login = String(ownerLogin || '').trim();
if (!login) return;
return transact(async records => {
const id = preferenceId(login);
if (!enabled) return records.delete(id);
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
});
}
async function getReceiptPreference(ownerLogin) {
const id = preferenceId(ownerLogin);
return transact(async records => Boolean(
(await records.getAll()).find(item => item.id === id)?.enabled
));
}
return {
reconcile,
upsert,
@@ -120,13 +142,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
snapshot: () => transact(records => records.getAll()),
snapshot: () => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
countBlocked: ownerLogin => transact(async records =>
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
setReceiptPreference,
getReceiptPreference,
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
}
if (item.kind === 'update-reply') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
}
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
}
function deliveryRequest(item) {
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
@@ -179,12 +220,13 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
try {
const delivered = await fetchJson(request.url, request.options);
await store.complete(item.id);
return item.kind ? { message: delivered } : { issue: delivered };
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
return { attention: true, error };
return { attention: true, error, receipt: receiptFor(item, 'attention') };
}
await store.release(item.id);
throw error;
@@ -205,8 +247,9 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
});
const login = String(identity?.login || '').trim();
const confirmed = [];
const receipts = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention };
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
while (true) {
const item = await store.claimNext(login);
if (!item) break;
@@ -214,15 +257,18 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
if (result.receipt) receipts.push(result.receipt);
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention };
return { confirmed, blocked, attention, login, receipts };
}
return {
flush, send,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
};
}
+25
View File
@@ -0,0 +1,25 @@
function createDetailDefer({
laterWork,
session,
close,
refresh,
focus,
announce,
formatTime = value => new Date(value).toLocaleString(),
}) {
return {
defer(item, preset) {
if (!item) return false;
const until = laterWork.presetUntil(preset);
if (!laterWork.defer(item, until)) return false;
const inSession = session.active();
if (!inSession) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
if (!inSession) focus();
return true;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
+84 -1
View File
@@ -84,6 +84,11 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.detail-defer { grid-column:1/-1; max-width:100%; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
@@ -287,6 +292,7 @@ textarea { resize: vertical; min-height: 120px; }
<div class="toolbar">
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button id="refresh">Refresh</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="open-palette">Command</button>
<span class="small muted" id="clock"></span>
</div>
@@ -325,8 +331,10 @@ textarea { resize: vertical; min-height: 120px; }
</label>
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
@@ -514,6 +522,7 @@ textarea { resize: vertical; min-height: 120px; }
<button id="release-issue" type="button">Release assignment</button>
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
@@ -617,6 +626,7 @@ textarea { resize: vertical; min-height: 120px; }
<button class="share-work-route" type="button">Share</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
@@ -656,6 +666,7 @@ textarea { resize: vertical; min-height: 120px; }
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
@@ -720,6 +731,7 @@ textarea { resize: vertical; min-height: 120px; }
</section>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@@ -738,6 +750,7 @@ textarea { resize: vertical; min-height: 120px; }
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/session.js"></script>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
@@ -750,6 +763,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/offline-work.js"></script>
<script src="static/my-work.js"></script>
<script src="static/later-work.js"></script>
<script src="static/detail-defer.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/issue-sheet.js"></script>
@@ -1255,6 +1269,30 @@ textarea { resize: vertical; min-height: 120px; }
},
});
const detailDefer = createDetailDefer({
laterWork,
session: workSession,
close: () => workRoute.close(),
refresh: refreshMyWorkView,
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
announce: message => { qs('#my-work-action-status').textContent = message; },
formatTime: fmt,
});
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
detailDefer.defer(item, button.dataset.detailDeferPreset);
});
});
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
button.addEventListener('click', () => {
const chooser = button.closest('.detail-defer');
chooser.open = false;
chooser.querySelector('summary')?.focus();
});
});
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
@@ -2374,7 +2412,10 @@ textarea { resize: vertical; min-height: 120px; }
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
updateDeliveryReceiptControls();
}
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale();
@@ -3321,12 +3362,33 @@ textarea { resize: vertical; min-height: 120px; }
const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status');
const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status');
function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled();
const saved = offlineWorkStore.load();
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
}
async function updateDeliveryReceiptControls(message) {
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
if (!supported) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
return;
}
if (!confirmedOwnerLogin) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
return;
}
deliveryReceipts.checked = Notification.permission === 'granted' &&
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
}
function setOfflineWorkMode(value) {
offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
@@ -3378,11 +3440,24 @@ textarea { resize: vertical; min-height: 120px; }
}
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
});
deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked;
if (enabled && Notification.permission !== 'granted') {
enabled = await Notification.requestPermission() === 'granted';
}
deliveryReceipts.checked = enabled;
if (backgroundIssueSync && confirmedOwnerLogin) {
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
}
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
});
qs('#clear-offline-work').addEventListener('click', () => {
offlineWorkStore.clear();
updateOfflineWorkControls('Offline work data cleared.');
});
updateOfflineWorkControls();
updateDeliveryReceiptControls();
if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
@@ -3457,6 +3532,14 @@ textarea { resize: vertical; min-height: 120px; }
updateWorkPaginationControls();
});
});
function openDeliveryReceiptRoute() {
if (window.location.hash !== '#/my-work/drafts') return;
qs('[data-work-filter="draft"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
window.addEventListener('hashchange', openDeliveryReceiptRoute);
openDeliveryReceiptRoute();
qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value;
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
+54 -4
View File
@@ -1,12 +1,13 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v14';
const CACHE = 'stackchain-dashboard-shell-v17';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
@@ -18,6 +19,7 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
@@ -30,8 +32,25 @@ const SHELL = [
BASE + 'static/background-issue-sync.js',
];
async function fetchJson(url, options) {
const response = await fetch(new URL(url, self.location.origin), options);
async function sessionCsrf() {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
});
if (!response.ok) return '';
const payload = await response.json().catch(() => ({}));
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(options.headers || {});
const csrf = await sessionCsrf();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetch(new URL(url, self.location.origin), requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
@@ -45,6 +64,23 @@ const issueSync = self.__issueSync || createBackgroundIssueSync({
store: createIssueSyncStore(), fetchJson, base: BASE,
});
async function flushAndNotify() {
const result = await issueSync.flush();
if (!result?.login || !result.receipts?.length ||
!await issueSync.getReceiptPreference?.(result.login)) return;
for (const receipt of result.receipts) {
const needsAttention = receipt.status === 'attention';
const title = needsAttention
? 'Queued work needs attention'
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
await self.registration.showNotification(title, {
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
});
}
}
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
@@ -57,7 +93,21 @@ self.addEventListener('activate', event => {
});
self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const route = String(event.notification.data?.route || '');
if (!route.startsWith('#/my-work/')) return;
const target = new URL(BASE + route, self.location.origin).href;
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
})());
});
self.addEventListener('fetch', event => {
+92
View File
@@ -0,0 +1,92 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else {
const base = new URL('./', root.location.href).pathname;
const originalFetch = root.fetch.bind(root);
const boundary = factory({
cookie: () => root.document.cookie,
origin: root.location.origin,
base,
fetchImpl: originalFetch,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
location: root.location,
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
});
root.fetch = boundary.fetch;
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
};
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,
onExpired = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
.map(value => value.trim())
.find(value => value.startsWith('stackchain_csrf='));
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
}
function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; }
}
async function sessionFetch(input, options = {}) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
const headers = new Headers(options.headers || input?.headers || {});
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
if (response.status === 401) onExpired();
return response;
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
try {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith('stackchain.')) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
}
async function signOut() {
try {
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
} finally {
await clearPrivateDeviceData();
location.assign(base + 'login');
}
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
});
+131
View File
@@ -0,0 +1,131 @@
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
import base64
import hashlib
import hmac
import ipaddress
import json
import os
import secrets
import time
from dataclasses import dataclass
from fastapi import Request
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
INSECURE_LOCAL_MODE = "insecure-local"
@dataclass(frozen=True)
class Session:
csrf: str
expires_at: int
def access_token() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
def mode() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
def configuration_error() -> str | None:
configured_mode = mode()
if configured_mode == INSECURE_LOCAL_MODE:
return None
if configured_mode != OPERATOR_MODE:
return "unsupported authentication mode"
operator_secret = access_token()
signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
return "operator mode requires two sufficiently long secrets"
if hmac.compare_digest(operator_secret, signing_secret):
return "operator and session secrets must be independent"
return None
def enabled() -> bool:
return mode() == OPERATOR_MODE and configuration_error() is None
def is_loopback_request(request: Request) -> bool:
host = request.client.host if request.client else ""
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host == "localhost"
def _secret() -> bytes:
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if configuration_error() is not None or mode() != OPERATOR_MODE:
raise RuntimeError("Dashboard authentication is not configured")
return configured.encode()
def _encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
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))
payload = json.dumps(
{"csrf": session.csrf, "exp": session.expires_at},
separators=(",", ":"),
sort_keys=True,
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
return f"{encoded}.{signature}", session
def verify_session(value: str | None, now: int | None = None) -> Session | None:
if not value or "." not in value or not enabled():
return None
encoded, supplied_signature = value.rsplit(".", 1)
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(supplied_signature, expected):
return None
try:
payload = json.loads(_decode(encoded))
session = Session(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:
return None
return session
def request_session(request: Request) -> Session | None:
return verify_session(request.cookies.get(SESSION_COOKIE))
def cookie_path(request: Request) -> str:
root_path = request.scope.get("root_path", "").rstrip("/")
return root_path or "/"
def application_path(request: Request) -> str:
path = request.url.path
root_path = request.scope.get("root_path", "").rstrip("/")
if root_path and (path == root_path or path.startswith(root_path + "/")):
return path[len(root_path):] or "/"
return path
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
+142 -15
View File
@@ -1,4 +1,5 @@
import asyncio
import hmac
import math
import os
import time
@@ -8,12 +9,12 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query
from fastapi.responses import JSONResponse
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import gitea_proxy
from src import dashboard_auth, gitea_proxy
from src.gitea_proxy import (
activity_events,
current_user,
@@ -124,6 +125,10 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
class DashboardSignIn(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024)
class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
@@ -423,21 +428,79 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
@app.middleware("http")
async def require_operator_session(request: Request, call_next):
path = dashboard_auth.application_path(request)
if path == "/healthz":
return await call_next(request)
if dashboard_auth.configuration_error() is not None:
return JSONResponse(
{"detail": "Dashboard authentication is not configured"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if dashboard_auth.mode() == dashboard_auth.INSECURE_LOCAL_MODE:
if not dashboard_auth.is_loopback_request(request):
return JSONResponse(
{"detail": "Insecure local mode requires a loopback client"},
status_code=403,
headers={"Cache-Control": "no-store"},
)
return await call_next(request)
public = (
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
or path.startswith("/static/")
or (path == "/api/v1/session" and request.method == "POST")
)
session = dashboard_auth.request_session(request)
if not public and session is None:
if path.startswith("/api/"):
return JSONResponse(
{"detail": "Authentication required"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"})
if not public and request.method not in {"GET", "HEAD", "OPTIONS"}:
supplied_csrf = request.headers.get("x-csrf-token", "")
if (
session is None
or not dashboard_auth.same_origin(request)
or not supplied_csrf
or not hmac.compare_digest(supplied_csrf, session.csrf)
):
return JSONResponse(
{"detail": "Valid same-origin CSRF proof required"},
status_code=403,
headers={"Cache-Control": "no-store"},
)
request.state.dashboard_session = session
response = await call_next(request)
if path in {"/login", "/api/v1/session"}:
response.headers["Cache-Control"] = "no-store"
return response
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.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"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or (
request.url.path.startswith("/api/v1/repos/")
and ("/issues/" in request.url.path or "/pulls/" in request.url.path)
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"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
path.startswith("/api/v1/repos/")
and ("/issues/" in path or "/pulls/" in path)
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/issues")
path.startswith("/api/v1/repos/")
and path.endswith("/issues")
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith(("/labels", "/milestones"))
path.startswith("/api/v1/repos/")
and path.endswith(("/labels", "/milestones"))
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -449,6 +512,68 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "stackchain-dashboard"}
@app.post("/api/v1/session")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
configured_token = dashboard_auth.access_token()
if not configured_token or not hmac.compare_digest(
payload.access_token, configured_token
):
raise HTTPException(status_code=401, detail="Invalid access token")
signed, session = dashboard_auth.issue_session()
path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie(
dashboard_auth.SESSION_COOKIE,
signed,
max_age=max_age,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.set_cookie(
dashboard_auth.CSRF_COOKIE,
session.csrf,
max_age=max_age,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {"authenticated": True}
@app.get("/api/v1/session")
async def session_status(request: Request):
session = dashboard_auth.request_session(request)
return {
"authenticated": session is not None,
"csrf_token": session.csrf if session is not None else "",
}
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.delete_cookie(
dashboard_auth.CSRF_COOKIE,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {"authenticated": False, "clear_private_device_data": True}
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
@@ -482,11 +607,13 @@ async def readiness():
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
} if timed_out else None,
)
return {
payload = {
"status": "ready",
"service": "stackchain-dashboard",
"gitea_user": user["login"],
}
if not dashboard_auth.enabled():
payload["gitea_user"] = user["login"]
return payload
@app.get("/api/v1/context")
+13
View File
@@ -8,6 +8,14 @@ DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.ht
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
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>
<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>"""
class RevalidatingHTMLResponse(HTMLResponse):
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
@@ -21,6 +29,11 @@ async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
@router.get("/login", response_class=RevalidatingHTMLResponse)
async def login() -> str:
return LOGIN_HTML
@router.get("/manifest.webmanifest", response_class=FileResponse)
async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
+107
View File
@@ -64,6 +64,59 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const queued = [
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
];
const store = {{
claimNext: async () => queued.shift() || null,
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
countBlocked: async () => 0,
}};
let issueMutations = 0;
const fetchJson = async (url) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["login"] == "timmy"
assert output["receipts"] == [
{
"id": "capture-1",
"status": "confirmed",
"kind": "issue",
"route": "#/my-work/issue/stackchain/api/44",
},
{
"id": "reply-1",
"status": "confirmed",
"kind": "message",
"route": "#/my-work/pull/stackchain/web/8",
},
{
"id": "bad-1",
"status": "attention",
"kind": "issue",
"route": "#/my-work/drafts",
},
]
serialized = json.dumps(output["receipts"])
assert "Secret" not in serialized
assert "Sensitive" not in serialized
@pytest.mark.parametrize(
("item", "expected_url"),
[
@@ -141,6 +194,47 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.setReceiptPreference('timmy', true);
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
const result={{
timmy:await store.getReceiptPreference('timmy'),
alexander:await store.getReceiptPreference('alexander'),
snapshot:await store.snapshot(),
blocked:await store.countBlocked('alexander'),
}};
await store.setReceiptPreference('timmy', false);
result.disabled=await store.getReceiptPreference('timmy');
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output == {
"timmy": True,
"alexander": False,
"snapshot": [
{
"id": "issue",
"ownerLogin": "timmy",
"status": "queued",
"outboxLane": "issue",
}
],
"blocked": 1,
"disabled": False,
}
def test_closed_app_sync_leaves_another_accounts_issue_queued():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -392,3 +486,16 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
assert "issueOutbox.reconcileBackground(records);" in html
assert "authoredOutbox.reconcileBackground(records);" in html
assert "if ('indexedDB' in window)" in html
@pytest.mark.anyio
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
html = await dashboard()
assert 'id="delivery-receipts" type="checkbox"' in html
assert "deliveryReceipts.addEventListener('change', async () => {" in html
assert "await Notification.requestPermission()" in html
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
+248
View File
@@ -0,0 +1,248 @@
import httpx
import pytest
from src import main
@pytest.fixture
def access_control(monkeypatch):
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")
@pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
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:
health = await client.get("/healthz")
responses = [
await client.get("/readyz"),
await client.get("/login"),
await client.get("/"),
await client.get("/api/v1/context"),
]
assert health.status_code == 200
assert [response.status_code for response in responses] == [503, 503, 503, 503]
assert all(response.headers["cache-control"] == "no-store" for response in responses)
assert all(response.json() == {
"detail": "Dashboard authentication is not configured"
} for response in responses)
assert called is False
@pytest.mark.anyio
async def test_operator_mode_rejects_reused_or_incomplete_secrets(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "same-secret-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "same-secret-with-at-least-thirty-two-characters")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
reused = await client.get("/login")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
incomplete = await client.get("/login")
assert reused.status_code == 503
assert incomplete.status_code == 503
assert "same-secret" not in reused.text
@pytest.mark.anyio
async def test_insecure_local_mode_is_restricted_to_loopback(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
local_transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
remote_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as client:
local = await client.get("/login")
async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as client:
remote = await client.get("/login")
assert local.status_code == 200
assert remote.status_code == 403
assert remote.json() == {"detail": "Insecure local mode requires a loopback client"}
@pytest.mark.anyio
async def test_anonymous_private_request_is_rejected_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:
response = await client.get("/api/v1/context")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
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 == 200
assert response.json() == {"authenticated": True}
cookies = response.headers.get_list("set-cookie")
assert any("stackchain_session=" in value and "HttpOnly" in value and "Secure" in value and "SameSite=strict" in value for value in cookies)
assert any("stackchain_csrf=" in value and "Secure" in value and "SameSite=strict" in value and "HttpOnly" not in value for value in cookies)
assert "correct horse battery staple" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
async def user():
return {"id": 1, "login": "timmy", "full_name": "", "email": ""}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
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"}
)
response = await client.get("/api/v1/context")
assert signed_in.status_code == 200
assert response.status_code == 200
assert response.json()["user"]["login"] == "timmy"
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
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"}
)
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
calls = 0
async def mark_read(notification_id):
nonlocal calls
calls += 1
return {"id": notification_id, "read": True}
monkeypatch.setattr(main, "mark_notification_read", mark_read)
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"}
)
missing = await client.patch("/api/v1/notifications/7/read")
foreign = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://evil.example",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
valid = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert missing.status_code == 403
assert foreign.status_code == 403
assert valid.status_code == 200
assert calls == 1
@pytest.mark.anyio
async def test_logout_clears_session_and_blocks_private_routes(access_control):
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"]
response = await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
private = await client.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {"authenticated": False, "clear_private_device_data": True}
assert private.status_code == 401
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
async def user():
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:
health = await client.get("/healthz")
login = await client.get("/login")
ready = await client.get("/readyz")
assert health.status_code == 200
assert login.status_code == 200
assert 'name="access_token"' in login.text
assert ready.status_code == 200
assert ready.json() == {"status": "ready", "service": "stackchain-dashboard"}
@pytest.mark.anyio
async def test_subpath_deployment_scopes_routes_and_session_cookies(access_control):
transport = httpx.ASGITransport(app=main.app, root_path="/dashboard")
async with httpx.AsyncClient(
transport=transport, base_url="https://test/dashboard/"
) as client:
login = await client.get("login")
signed_in = await client.post(
"api/v1/session",
json={"access_token": "correct horse battery staple"},
)
status = await client.get("api/v1/session")
assert login.status_code == 200
assert signed_in.status_code == 200
assert status.status_code == 200
assert any(
"stackchain_session=" in value and "Path=/dashboard" in value
for value in signed_in.headers.get_list("set-cookie")
)
+81
View File
@@ -0,0 +1,81 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
ROOT = Path(__file__).resolve().parents[1]
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 storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
origin: 'https://forge.example',
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
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); }} }},
location: {{ assign: value => {{ state.assigned = value; }} }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(result.stdout)
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
process.stdout.write(JSON.stringify(state));
"""
)
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario(
"""
await boundary.signOut();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/session"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
@pytest.mark.anyio
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
html = await dashboard()
assert '<script src="static/session.js"></script>' in html
assert html.index('static/session.js') < html.index('static/markdown.js')
assert '<button id="sign-out" type="button">Sign out &amp; clear this device</button>' in html
+60
View File
@@ -10,6 +10,7 @@ from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
@@ -838,6 +839,65 @@ process.stdout.write(JSON.stringify({{
}
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
let sessionActive = false;
const controller = createDetailDefer({{
laterWork: {{
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }},
}},
session: {{ active:() => sessionActive }},
close:() => calls.push(['close']),
refresh:() => calls.push(['refresh']),
focus:() => calls.push(['focus']),
announce:message => calls.push(['announce', message]),
formatTime:value => value.toISOString(),
}});
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
const outside = controller.defer(item, 'today');
sessionActive = true;
const session = controller.defer(item, 'tomorrow');
process.stdout.write(JSON.stringify({{outside,session,calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"outside": True,
"session": True,
"calls": [
["defer", "Read first", "2026-08-08T16:00:00.000Z"],
["close"],
["refresh"],
["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."],
["focus"],
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
["refresh"],
["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."],
],
}
@pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
html = await dashboard()
assert '<script src="static/detail-defer.js"></script>' in html
assert html.count('class="detail-defer"') == 4
assert html.count('data-detail-defer-preset="today"') == 4
assert html.count('data-detail-defer-preset="tomorrow"') == 4
assert 'const detailDefer = createDetailDefer({' in html
assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html
assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html
assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html
assert "fetch(" not in DETAIL_DEFER.read_text()
@pytest.mark.anyio
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
html = await dashboard()
+71 -5
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
@@ -26,8 +26,16 @@ const context = {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{ claim: async () => {{ state.claimed = true; }} }},
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
clients: {{
claim: async () => {{ state.claimed = true; }},
matchAll: async () => state.clientList || [],
openWindow: async url => {{ state.opened.push(url); }},
}},
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
importScripts: () => {{}},
caches: {{
@@ -60,6 +68,14 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
let pending;
listeners.notificationclick({{
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
@@ -70,10 +86,10 @@ async function dispatchSync(tag) {{
return json.loads(completed.stdout)
def test_background_authored_sync_ships_in_a_new_shell_cache():
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v14" in source
assert "stackchain-dashboard-shell-v17" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@@ -88,6 +104,54 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
assert result["backgroundFlushes"] == 1
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario(
"""
state.receiptLogin = 'timmy';
state.flushResult = {login:'timmy', receipts:[
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
]};
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
await dispatchSync('stackchain-issue-outbox-v1');
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [
{
"title": "Queued issue created",
"options": {
"body": "Tap to open it in Stackchain.",
"tag": "stackchain-delivery-capture-1",
"data": {"route": "#/my-work/issue/stackchain/api/44"},
},
},
{
"title": "Queued work needs attention",
"options": {
"body": "Tap to review it in Drafts.",
"tag": "stackchain-delivery-bad-1",
"data": {"route": "#/my-work/drafts"},
},
},
]
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
]
assert result["opened"] == []
assert result["notificationClosed"] is True
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()
assert "async function sessionCsrf()" in source
assert "BASE + 'api/v1/session'" in source
assert "headers.set('X-CSRF-Token', csrf)" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
@@ -103,6 +167,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/manifest.webmanifest",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
@@ -114,6 +179,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",