Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94aad3e76e | ||
|
|
00a956b1fb | ||
|
|
7bdfe62495 | ||
|
|
1b10a5a8a8 | ||
|
|
a7afd85634 |
No files matched your search
@@ -45,16 +45,18 @@ comment path and submits the summary, decision, and inline comments in one revie
|
||||
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. For any deployment not already behind an
|
||||
authenticated gateway, configure the built-in single-operator boundary with two
|
||||
independent high-entropy secrets. The access token is entered at `/login`; the
|
||||
browser receives only a short-lived signed session and CSRF proof. Generate the
|
||||
values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
||||
access is intentionally disabled. Authentication defaults to fail-closed
|
||||
`operator` mode. It requires two independent secrets of at least 24 characters;
|
||||
missing, short, reused, or invalid configuration leaves `/healthz` available but
|
||||
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
|
||||
the browser receives only a short-lived signed session and CSRF proof. Generate
|
||||
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
||||
them at runtime—never commit them:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
# Optional; defaults to eight hours.
|
||||
@@ -68,11 +70,15 @@ 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.
|
||||
all sessions immediately. The signing key is never derived from the access token.
|
||||
|
||||
Open `http://127.0.0.1:8000/` only for an unprotected local development run. To
|
||||
verify the backend and its Gitea connection directly, sign in and request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
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,
|
||||
@@ -141,7 +147,15 @@ and registered with Background Sync, so a supporting installed browser can deliv
|
||||
new issues, issue comments, pull-request comments, and unread-update replies after
|
||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Browsers without
|
||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
||||
work finishes**; permission is requested only from that user gesture and the choice
|
||||
is stored for the confirmed account in the private background outbox database.
|
||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||
source conversation, while permanent validation failures open Drafts for recovery.
|
||||
Notification text never includes issue titles, comment bodies, or validation details.
|
||||
The preference is off by default, unsupported or denied browsers retain foreground
|
||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
||||
Browsers without
|
||||
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||
|
||||
@@ -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)
|
||||
@@ -48,6 +48,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const existing = await records.getAll();
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||
for (const current of existing) {
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
@@ -112,6 +113,27 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
|
||||
async function setReceiptPreference(ownerLogin, enabled) {
|
||||
const login = String(ownerLogin || '').trim();
|
||||
if (!login) return;
|
||||
return transact(async records => {
|
||||
const id = preferenceId(login);
|
||||
if (!enabled) return records.delete(id);
|
||||
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function getReceiptPreference(ownerLogin) {
|
||||
const id = preferenceId(ownerLogin);
|
||||
return transact(async records => Boolean(
|
||||
(await records.getAll()).find(item => item.id === id)?.enabled
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
reconcile,
|
||||
upsert,
|
||||
@@ -120,13 +142,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
||||
snapshot: () => transact(records => records.getAll()),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
setReceiptPreference,
|
||||
getReceiptPreference,
|
||||
};
|
||||
}
|
||||
|
||||
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
function receiptFor(item, status, delivered = {}) {
|
||||
if (status === 'attention') {
|
||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
function deliveryRequest(item) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
@@ -179,12 +220,13 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
try {
|
||||
const delivered = await fetchJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
return item.kind ? { message: delivered } : { issue: delivered };
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||
return { attention: true, error };
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
@@ -205,8 +247,9 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
});
|
||||
const login = String(identity?.login || '').trim();
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention };
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
while (true) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
@@ -214,15 +257,18 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention };
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+48
-1
@@ -331,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>
|
||||
@@ -2410,7 +2412,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
updateDeliveryReceiptControls();
|
||||
}
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
@@ -3357,12 +3362,33 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const offlineStatus = qs('#offline-status');
|
||||
const keepWorkOffline = qs('#keep-work-offline');
|
||||
const offlineWorkStatus = qs('#offline-work-status');
|
||||
const deliveryReceipts = qs('#delivery-receipts');
|
||||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
||||
function updateOfflineWorkControls(message) {
|
||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||
const saved = offlineWorkStore.load();
|
||||
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||||
}
|
||||
async function updateDeliveryReceiptControls(message) {
|
||||
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
|
||||
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
|
||||
if (!supported) {
|
||||
deliveryReceipts.checked = false;
|
||||
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
|
||||
return;
|
||||
}
|
||||
if (!confirmedOwnerLogin) {
|
||||
deliveryReceipts.checked = false;
|
||||
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
|
||||
return;
|
||||
}
|
||||
deliveryReceipts.checked = Notification.permission === 'granted' &&
|
||||
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
|
||||
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
|
||||
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
|
||||
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
|
||||
}
|
||||
function setOfflineWorkMode(value) {
|
||||
offlineWorkMode = value;
|
||||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||||
@@ -3414,11 +3440,24 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
});
|
||||
deliveryReceipts.addEventListener('change', async () => {
|
||||
let enabled = deliveryReceipts.checked;
|
||||
if (enabled && Notification.permission !== 'granted') {
|
||||
enabled = await Notification.requestPermission() === 'granted';
|
||||
}
|
||||
deliveryReceipts.checked = enabled;
|
||||
if (backgroundIssueSync && confirmedOwnerLogin) {
|
||||
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
|
||||
}
|
||||
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
|
||||
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
|
||||
});
|
||||
qs('#clear-offline-work').addEventListener('click', () => {
|
||||
offlineWorkStore.clear();
|
||||
updateOfflineWorkControls('Offline work data cleared.');
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
updateDeliveryReceiptControls();
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
@@ -3493,6 +3532,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
updateWorkPaginationControls();
|
||||
});
|
||||
});
|
||||
function openDeliveryReceiptRoute() {
|
||||
if (window.location.hash !== '#/my-work/drafts') return;
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
window.addEventListener('hashchange', openDeliveryReceiptRoute);
|
||||
openDeliveryReceiptRoute();
|
||||
qs('#work-milestone-filter').addEventListener('change', event => {
|
||||
selectedWorkMilestone = event.target.value;
|
||||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v16';
|
||||
const CACHE = 'stackchain-dashboard-shell-v17';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
@@ -64,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()));
|
||||
});
|
||||
@@ -76,7 +93,21 @@ self.addEventListener('activate', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
|
||||
+35
-6
@@ -3,6 +3,7 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
@@ -14,6 +15,9 @@ from fastapi import Request
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
INSECURE_LOCAL_MODE = "insecure-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -26,17 +30,42 @@ 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 bool(access_token())
|
||||
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 configured:
|
||||
return configured.encode()
|
||||
return hmac.new(
|
||||
access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256
|
||||
).digest()
|
||||
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:
|
||||
|
||||
+18
-2
@@ -430,10 +430,26 @@ app.include_router(frontend_router)
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_operator_session(request: Request, call_next):
|
||||
if not dashboard_auth.enabled():
|
||||
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)
|
||||
|
||||
path = dashboard_auth.application_path(request)
|
||||
public = (
|
||||
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
|
||||
or path.startswith("/static/")
|
||||
|
||||
@@ -64,6 +64,59 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const queued = [
|
||||
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
|
||||
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
|
||||
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
|
||||
];
|
||||
const store = {{
|
||||
claimNext: async () => queued.shift() || null,
|
||||
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
let issueMutations = 0;
|
||||
const fetchJson = async (url) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
|
||||
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
|
||||
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["login"] == "timmy"
|
||||
assert output["receipts"] == [
|
||||
{
|
||||
"id": "capture-1",
|
||||
"status": "confirmed",
|
||||
"kind": "issue",
|
||||
"route": "#/my-work/issue/stackchain/api/44",
|
||||
},
|
||||
{
|
||||
"id": "reply-1",
|
||||
"status": "confirmed",
|
||||
"kind": "message",
|
||||
"route": "#/my-work/pull/stackchain/web/8",
|
||||
},
|
||||
{
|
||||
"id": "bad-1",
|
||||
"status": "attention",
|
||||
"kind": "issue",
|
||||
"route": "#/my-work/drafts",
|
||||
},
|
||||
]
|
||||
serialized = json.dumps(output["receipts"])
|
||||
assert "Secret" not in serialized
|
||||
assert "Sensitive" not in serialized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("item", "expected_url"),
|
||||
[
|
||||
@@ -141,6 +194,47 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.setReceiptPreference('timmy', true);
|
||||
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
||||
const result={{
|
||||
timmy:await store.getReceiptPreference('timmy'),
|
||||
alexander:await store.getReceiptPreference('alexander'),
|
||||
snapshot:await store.snapshot(),
|
||||
blocked:await store.countBlocked('alexander'),
|
||||
}};
|
||||
await store.setReceiptPreference('timmy', false);
|
||||
result.disabled=await store.getReceiptPreference('timmy');
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"timmy": True,
|
||||
"alexander": False,
|
||||
"snapshot": [
|
||||
{
|
||||
"id": "issue",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "queued",
|
||||
"outboxLane": "issue",
|
||||
}
|
||||
],
|
||||
"blocked": 1,
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -392,3 +486,16 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
||||
assert "issueOutbox.reconcileBackground(records);" in html
|
||||
assert "authoredOutbox.reconcileBackground(records);" in html
|
||||
assert "if ('indexedDB' in window)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="delivery-receipts" type="checkbox"' in html
|
||||
assert "deliveryReceipts.addEventListener('change', async () => {" in html
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
@@ -6,10 +6,72 @@ from src import main
|
||||
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
called = False
|
||||
|
||||
async def user():
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
health = await client.get("/healthz")
|
||||
responses = [
|
||||
await client.get("/readyz"),
|
||||
await client.get("/login"),
|
||||
await client.get("/"),
|
||||
await client.get("/api/v1/context"),
|
||||
]
|
||||
|
||||
assert health.status_code == 200
|
||||
assert [response.status_code for response in responses] == [503, 503, 503, 503]
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in responses)
|
||||
assert all(response.json() == {
|
||||
"detail": "Dashboard authentication is not configured"
|
||||
} for response in responses)
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_mode_rejects_reused_or_incomplete_secrets(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "same-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "same-secret-with-at-least-thirty-two-characters")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
reused = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
incomplete = await client.get("/login")
|
||||
|
||||
assert reused.status_code == 503
|
||||
assert incomplete.status_code == 503
|
||||
assert "same-secret" not in reused.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insecure_local_mode_is_restricted_to_loopback(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
local_transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
|
||||
remote_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
|
||||
async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as client:
|
||||
local = await client.get("/login")
|
||||
async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as client:
|
||||
remote = await client.get("/login")
|
||||
|
||||
assert local.status_code == 200
|
||||
assert remote.status_code == 403
|
||||
assert remote.json() == {"detail": "Insecure local mode requires a loopback client"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch):
|
||||
called = False
|
||||
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
@@ -26,8 +26,16 @@ const context = {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
|
||||
clients: {{
|
||||
claim: async () => {{ state.claimed = true; }},
|
||||
matchAll: async () => state.clientList || [],
|
||||
openWindow: async url => {{ state.opened.push(url); }},
|
||||
}},
|
||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
||||
getReceiptPreference: async login => state.receiptLogin === login,
|
||||
}},
|
||||
}},
|
||||
importScripts: () => {{}},
|
||||
caches: {{
|
||||
@@ -60,6 +68,14 @@ async function dispatchSync(tag) {{
|
||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route) {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
@@ -70,10 +86,10 @@ async function dispatchSync(tag) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_operator_session_boundary_ships_in_a_new_shell_cache():
|
||||
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v16" in source
|
||||
assert "stackchain-dashboard-shell-v17" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -88,6 +104,46 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.receiptLogin = 'timmy';
|
||||
state.flushResult = {login:'timmy', receipts:[
|
||||
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
|
||||
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
|
||||
]};
|
||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [
|
||||
{
|
||||
"title": "Queued issue created",
|
||||
"options": {
|
||||
"body": "Tap to open it in Stackchain.",
|
||||
"tag": "stackchain-delivery-capture-1",
|
||||
"data": {"route": "#/my-work/issue/stackchain/api/44"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"title": "Queued work needs attention",
|
||||
"options": {
|
||||
"body": "Tap to review it in Drafts.",
|
||||
"tag": "stackchain-delivery-bad-1",
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
||||
]
|
||||
assert result["opened"] == []
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||
source = WORKER.read_text()
|
||||
|
||||
|
||||
Reference in new issue
Block a user