Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac1277479 | ||
|
|
6185f6a7ff | ||
|
|
98d6a29fa0 | ||
|
|
3decdb4cac | ||
|
|
0fa3e675dc | ||
|
|
3210b8be6d | ||
|
|
87becb08ad | ||
|
|
334fe77e84 | ||
|
|
c8de28ca0c | ||
|
|
741d768fc3 | ||
|
|
acd2d82d10 | ||
|
|
65d1f9f194 | ||
|
|
c74c63d29c | ||
|
|
94aad3e76e | ||
|
|
00a956b1fb | ||
|
|
7bdfe62495 | ||
|
|
1b10a5a8a8 | ||
|
|
a7afd85634 | ||
|
|
39357263d0 | ||
|
|
e40c75b360 | ||
|
|
fdb39e43ad | ||
|
|
61168dbfd4 |
No files matched your search
@@ -44,19 +44,68 @@ 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 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
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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 +174,19 @@ 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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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,17 @@ 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 === 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 };
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
@@ -205,8 +251,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 +261,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
+116
-12
@@ -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 & 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 & 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>
|
||||
@@ -919,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 });
|
||||
@@ -1255,6 +1270,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 +2413,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();
|
||||
@@ -2778,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();
|
||||
@@ -2997,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();
|
||||
@@ -3098,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();
|
||||
@@ -3321,12 +3383,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 +3461,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 +3553,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); }
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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-v19';
|
||||
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,25 @@ 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('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 || '');
|
||||
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 => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
(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,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
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());
|
||||
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, serviceWorker, 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');
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
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
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
INSECURE_LOCAL_MODE = "insecure-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
session_id: str
|
||||
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 _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(
|
||||
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, "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
|
||||
|
||||
|
||||
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(
|
||||
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.session_id, str)
|
||||
or not session.session_id
|
||||
or not isinstance(session.csrf, str)
|
||||
or not session.csrf
|
||||
):
|
||||
return None
|
||||
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
@@ -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
|
||||
+222
-15
@@ -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,
|
||||
@@ -27,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
|
||||
@@ -124,6 +126,24 @@ 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -423,21 +443,88 @@ 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 = 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(
|
||||
{"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 +536,124 @@ 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):
|
||||
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")
|
||||
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(
|
||||
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 = request.state.dashboard_session
|
||||
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):
|
||||
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,
|
||||
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 +687,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")
|
||||
|
||||
@@ -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
|
||||
@@ -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{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 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):
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))});
|
||||
@@ -166,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))});
|
||||
@@ -392,3 +524,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
|
||||
@@ -0,0 +1,489 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
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_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():
|
||||
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_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)
|
||||
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_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():
|
||||
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")
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
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: '', workerMessages: [] }};
|
||||
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); }} }},
|
||||
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); }});
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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 & clear this device</button>' in html
|
||||
@@ -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
|
||||
|
||||
@@ -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,)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -312,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"},
|
||||
@@ -838,6 +891,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()
|
||||
|
||||
@@ -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,19 @@ 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({{
|
||||
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 +91,10 @@ async function dispatchSync(tag) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_background_authored_sync_ships_in_a_new_shell_cache():
|
||||
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v14" in source
|
||||
assert "stackchain-dashboard-shell-v19" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -88,6 +109,65 @@ 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(
|
||||
"""
|
||||
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 +183,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 +195,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",
|
||||
|
||||
@@ -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,)
|
||||
Reference in new issue
Block a user