Merge pull request 'Require operator sessions for privileged dashboard access' (#259) from timmy/258-operator-session into main
This commit is contained in:
commit
a7afd85634
26
README.md
26
README.md
|
|
@ -44,18 +44,34 @@ 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
|
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
|
request. The dashboard rechecks the current pull-request head, CI success, draft
|
||||||
state, and mergeability immediately before every merge.
|
state, and mergeability immediately before every merge.
|
||||||
Serve the dashboard
|
Serve the dashboard only to trusted users on its own origin; cross-origin API
|
||||||
only to trusted users on its own origin; cross-origin API
|
access is intentionally disabled. For any deployment not already behind an
|
||||||
access is intentionally disabled. Then start the API and bundled frontend:
|
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
|
||||||
|
them at runtime—never commit them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GITEA_URL='https://forge.example.com'
|
export GITEA_URL='https://forge.example.com'
|
||||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||||
|
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||||
|
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||||
|
# Optional; defaults to eight hours.
|
||||||
|
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
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
|
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
||||||
Gitea connection directly, request
|
`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.
|
||||||
|
|
||||||
|
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
|
`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
|
`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
|
to search commands plus issues and pull requests across every repository visible to
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||||
<button id="refresh">Refresh</button>
|
<button id="refresh">Refresh</button>
|
||||||
|
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||||
<button id="open-palette">Command</button>
|
<button id="open-palette">Command</button>
|
||||||
<span class="small muted" id="clock"></span>
|
<span class="small muted" id="clock"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -747,6 +748,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
<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/markdown.js"></script>
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v15';
|
const CACHE = 'stackchain-dashboard-shell-v16';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
BASE + 'manifest.webmanifest',
|
BASE + 'manifest.webmanifest',
|
||||||
BASE + 'static/icons/stackchain-192.png',
|
BASE + 'static/icons/stackchain-192.png',
|
||||||
BASE + 'static/icons/stackchain-512.png',
|
BASE + 'static/icons/stackchain-512.png',
|
||||||
|
BASE + 'static/session.js',
|
||||||
BASE + 'static/markdown.js',
|
BASE + 'static/markdown.js',
|
||||||
BASE + 'static/commands.js',
|
BASE + 'static/commands.js',
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
|
|
@ -31,8 +32,25 @@ const SHELL = [
|
||||||
BASE + 'static/background-issue-sync.js',
|
BASE + 'static/background-issue-sync.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchJson(url, options) {
|
async function sessionCsrf() {
|
||||||
const response = await fetch(new URL(url, self.location.origin), options);
|
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(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
|
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
|
||||||
|
|
|
||||||
92
frontend/session.js
Normal file
92
frontend/session.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||||
|
else {
|
||||||
|
const base = new URL('./', root.location.href).pathname;
|
||||||
|
const originalFetch = root.fetch.bind(root);
|
||||||
|
const boundary = factory({
|
||||||
|
cookie: () => root.document.cookie,
|
||||||
|
origin: root.location.origin,
|
||||||
|
base,
|
||||||
|
fetchImpl: originalFetch,
|
||||||
|
localStorage: root.localStorage,
|
||||||
|
sessionStorage: root.sessionStorage,
|
||||||
|
indexedDB: root.indexedDB,
|
||||||
|
caches: root.caches,
|
||||||
|
location: root.location,
|
||||||
|
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||||
|
});
|
||||||
|
root.fetch = boundary.fetch;
|
||||||
|
const attach = () => {
|
||||||
|
const button = root.document.getElementById('sign-out');
|
||||||
|
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||||
|
};
|
||||||
|
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||||
|
else attach();
|
||||||
|
root.stackchainSession = boundary;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||||
|
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location,
|
||||||
|
onExpired = () => {},
|
||||||
|
}) {
|
||||||
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
const entry = String(cookie?.() || '').split(';')
|
||||||
|
.map(value => value.trim())
|
||||||
|
.find(value => value.startsWith('stackchain_csrf='));
|
||||||
|
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOrigin(input) {
|
||||||
|
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||||||
|
catch (_error) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionFetch(input, options = {}) {
|
||||||
|
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||||
|
const requestOptions = { ...options };
|
||||||
|
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
|
||||||
|
const headers = new Headers(options.headers || input?.headers || {});
|
||||||
|
const csrf = csrfToken();
|
||||||
|
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||||
|
requestOptions.headers = headers;
|
||||||
|
}
|
||||||
|
const response = await fetchImpl(input, requestOptions);
|
||||||
|
if (response.status === 401) onExpired();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDashboardStorage(storage) {
|
||||||
|
if (!storage) return;
|
||||||
|
const keys = [];
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const key = storage.key(index);
|
||||||
|
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||||
|
}
|
||||||
|
keys.forEach(key => storage.removeItem(key));
|
||||||
|
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPrivateDeviceData() {
|
||||||
|
removeDashboardStorage(localStorage);
|
||||||
|
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||||
|
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||||
|
catch (_error) { /* Continue clearing other dashboard state. */ }
|
||||||
|
try {
|
||||||
|
const keys = await caches?.keys?.() || [];
|
||||||
|
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||||||
|
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try {
|
||||||
|
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
|
||||||
|
} finally {
|
||||||
|
await clearPrivateDeviceData();
|
||||||
|
location.assign(base + 'login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
|
||||||
|
});
|
||||||
102
src/dashboard_auth.py
Normal file
102
src/dashboard_auth.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
SESSION_COOKIE = "stackchain_session"
|
||||||
|
CSRF_COOKIE = "stackchain_csrf"
|
||||||
|
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Session:
|
||||||
|
csrf: str
|
||||||
|
expires_at: int
|
||||||
|
|
||||||
|
|
||||||
|
def access_token() -> str:
|
||||||
|
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(access_token())
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(value: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def issue_session(now: int | None = None) -> tuple[str, Session]:
|
||||||
|
issued_at = int(time.time() if now is None else now)
|
||||||
|
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||||
|
session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl))
|
||||||
|
payload = json.dumps(
|
||||||
|
{"csrf": session.csrf, "exp": session.expires_at},
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
).encode()
|
||||||
|
encoded = _encode(payload)
|
||||||
|
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||||
|
return f"{encoded}.{signature}", session
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||||
|
if not value or "." not in value or not enabled():
|
||||||
|
return None
|
||||||
|
encoded, supplied_signature = value.rsplit(".", 1)
|
||||||
|
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||||
|
if not hmac.compare_digest(supplied_signature, expected):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(_decode(encoded))
|
||||||
|
session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"]))
|
||||||
|
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
current = int(time.time() if now is None else now)
|
||||||
|
if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf:
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def request_session(request: Request) -> Session | None:
|
||||||
|
return verify_session(request.cookies.get(SESSION_COOKIE))
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_path(request: Request) -> str:
|
||||||
|
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||||
|
return root_path or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def application_path(request: Request) -> str:
|
||||||
|
path = request.url.path
|
||||||
|
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||||
|
if root_path and (path == root_path or path.startswith(root_path + "/")):
|
||||||
|
return path[len(root_path):] or "/"
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def same_origin(request: Request) -> bool:
|
||||||
|
origin = request.headers.get("origin", "")
|
||||||
|
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||||
|
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||||
141
src/main.py
141
src/main.py
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hmac
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
@ -8,12 +9,12 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query
|
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
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 (
|
from src.gitea_proxy import (
|
||||||
activity_events,
|
activity_events,
|
||||||
current_user,
|
current_user,
|
||||||
|
|
@ -124,6 +125,10 @@ class ReadinessPayloadError(ValueError):
|
||||||
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSignIn(BaseModel):
|
||||||
|
access_token: str = Field(min_length=1, max_length=1_024)
|
||||||
|
|
||||||
|
|
||||||
class NotificationReadBatch(BaseModel):
|
class NotificationReadBatch(BaseModel):
|
||||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
@ -423,21 +428,63 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
app.include_router(frontend_router)
|
app.include_router(frontend_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def require_operator_session(request: Request, call_next):
|
||||||
|
if not dashboard_auth.enabled():
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = dashboard_auth.application_path(request)
|
||||||
|
public = (
|
||||||
|
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
|
||||||
|
or path.startswith("/static/")
|
||||||
|
or (path == "/api/v1/session" and request.method == "POST")
|
||||||
|
)
|
||||||
|
session = dashboard_auth.request_session(request)
|
||||||
|
if not public and session is None:
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Authentication required"},
|
||||||
|
status_code=401,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
if not public and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
supplied_csrf = request.headers.get("x-csrf-token", "")
|
||||||
|
if (
|
||||||
|
session is None
|
||||||
|
or not dashboard_auth.same_origin(request)
|
||||||
|
or not supplied_csrf
|
||||||
|
or not hmac.compare_digest(supplied_csrf, session.csrf)
|
||||||
|
):
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Valid same-origin CSRF proof required"},
|
||||||
|
status_code=403,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
request.state.dashboard_session = session
|
||||||
|
response = await call_next(request)
|
||||||
|
if path in {"/login", "/api/v1/session"}:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
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 (
|
path = dashboard_auth.application_path(request)
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
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 (
|
||||||
and request.url.path.endswith("/review")
|
path.startswith("/api/v1/repos/")
|
||||||
) or request.url.path.startswith("/api/v1/notifications") or (
|
and path.endswith("/review")
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
and ("/issues/" in request.url.path or "/pulls/" in request.url.path)
|
path.startswith("/api/v1/repos/")
|
||||||
|
and ("/issues/" in path or "/pulls/" in path)
|
||||||
) or (
|
) or (
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and request.url.path.endswith("/issues")
|
and path.endswith("/issues")
|
||||||
) or (
|
) or (
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and request.url.path.endswith(("/labels", "/milestones"))
|
and path.endswith(("/labels", "/milestones"))
|
||||||
):
|
):
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
|
|
@ -449,6 +496,68 @@ def health() -> dict[str, str]:
|
||||||
return {"status": "ok", "service": "stackchain-dashboard"}
|
return {"status": "ok", "service": "stackchain-dashboard"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/session")
|
||||||
|
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
|
||||||
|
configured_token = dashboard_auth.access_token()
|
||||||
|
if not configured_token or not hmac.compare_digest(
|
||||||
|
payload.access_token, configured_token
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||||
|
signed, session = dashboard_auth.issue_session()
|
||||||
|
path = dashboard_auth.cookie_path(request)
|
||||||
|
max_age = max(1, session.expires_at - int(time.time()))
|
||||||
|
response.set_cookie(
|
||||||
|
dashboard_auth.SESSION_COOKIE,
|
||||||
|
signed,
|
||||||
|
max_age=max_age,
|
||||||
|
path=path,
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
dashboard_auth.CSRF_COOKIE,
|
||||||
|
session.csrf,
|
||||||
|
max_age=max_age,
|
||||||
|
path=path,
|
||||||
|
secure=True,
|
||||||
|
httponly=False,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/session")
|
||||||
|
async def session_status(request: Request):
|
||||||
|
session = dashboard_auth.request_session(request)
|
||||||
|
return {
|
||||||
|
"authenticated": session is not None,
|
||||||
|
"csrf_token": session.csrf if session is not None else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/session")
|
||||||
|
async def sign_out(request: Request, response: Response):
|
||||||
|
path = dashboard_auth.cookie_path(request)
|
||||||
|
response.delete_cookie(
|
||||||
|
dashboard_auth.SESSION_COOKIE,
|
||||||
|
path=path,
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
response.delete_cookie(
|
||||||
|
dashboard_auth.CSRF_COOKIE,
|
||||||
|
path=path,
|
||||||
|
secure=True,
|
||||||
|
httponly=False,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return {"authenticated": False, "clear_private_device_data": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/readyz")
|
@app.get("/readyz")
|
||||||
async def readiness():
|
async def readiness():
|
||||||
"""Return readiness after verifying the configured Gitea connection."""
|
"""Return readiness after verifying the configured Gitea connection."""
|
||||||
|
|
@ -482,11 +591,13 @@ async def readiness():
|
||||||
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
|
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
|
||||||
} if timed_out else None,
|
} if timed_out else None,
|
||||||
)
|
)
|
||||||
return {
|
payload = {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"service": "stackchain-dashboard",
|
"service": "stackchain-dashboard",
|
||||||
"gitea_user": user["login"],
|
|
||||||
}
|
}
|
||||||
|
if not dashboard_auth.enabled():
|
||||||
|
payload["gitea_user"] = user["login"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/context")
|
@app.get("/api/v1/context")
|
||||||
|
|
|
||||||
13
src/views.py
13
src/views.py
|
|
@ -8,6 +8,14 @@ DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.ht
|
||||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||||
|
|
||||||
|
LOGIN_HTML = """<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Sign in · Stackchain Dashboard</title>
|
||||||
|
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||||
|
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||||
|
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
|
||||||
|
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
class RevalidatingHTMLResponse(HTMLResponse):
|
class RevalidatingHTMLResponse(HTMLResponse):
|
||||||
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
|
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()
|
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)
|
@router.get("/manifest.webmanifest", response_class=FileResponse)
|
||||||
async def web_app_manifest() -> FileResponse:
|
async def web_app_manifest() -> FileResponse:
|
||||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||||
|
|
|
||||||
186
tests/test_dashboard_auth.py
Normal file
186
tests/test_dashboard_auth.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def access_control(monkeypatch):
|
||||||
|
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_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch):
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
response = await client.get("/api/v1/context")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"detail": "Authentication required"}
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"authenticated": True}
|
||||||
|
cookies = response.headers.get_list("set-cookie")
|
||||||
|
assert any("stackchain_session=" in value and "HttpOnly" in value and "Secure" in value and "SameSite=strict" in value for value in cookies)
|
||||||
|
assert any("stackchain_csrf=" in value and "Secure" in value and "SameSite=strict" in value and "HttpOnly" not in value for value in cookies)
|
||||||
|
assert "correct horse battery staple" not in response.text
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "timmy", "full_name": "", "email": ""}
|
||||||
|
|
||||||
|
async def empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main, "repos", empty)
|
||||||
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
signed_in = await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
response = await client.get("/api/v1/context")
|
||||||
|
|
||||||
|
assert signed_in.status_code == 200
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["user"]["login"] == "timmy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
response = await client.get("/api/v1/session")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"authenticated": True,
|
||||||
|
"csrf_token": client.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
assert "correct horse battery staple" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def mark_read(notification_id):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {"id": notification_id, "read": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "mark_notification_read", mark_read)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
missing = await client.patch("/api/v1/notifications/7/read")
|
||||||
|
foreign = await client.patch(
|
||||||
|
"/api/v1/notifications/7/read",
|
||||||
|
headers={
|
||||||
|
"Origin": "https://evil.example",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
valid = await client.patch(
|
||||||
|
"/api/v1/notifications/7/read",
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert missing.status_code == 403
|
||||||
|
assert foreign.status_code == 403
|
||||||
|
assert valid.status_code == 200
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
csrf = client.cookies["stackchain_csrf"]
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/v1/session",
|
||||||
|
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||||
|
)
|
||||||
|
private = await client.get("/api/v1/background-identity")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"authenticated": False, "clear_private_device_data": True}
|
||||||
|
assert private.status_code == 401
|
||||||
|
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
health = await client.get("/healthz")
|
||||||
|
login = await client.get("/login")
|
||||||
|
ready = await client.get("/readyz")
|
||||||
|
|
||||||
|
assert health.status_code == 200
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert 'name="access_token"' in login.text
|
||||||
|
assert ready.status_code == 200
|
||||||
|
assert ready.json() == {"status": "ready", "service": "stackchain-dashboard"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_subpath_deployment_scopes_routes_and_session_cookies(access_control):
|
||||||
|
transport = httpx.ASGITransport(app=main.app, root_path="/dashboard")
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="https://test/dashboard/"
|
||||||
|
) as client:
|
||||||
|
login = await client.get("login")
|
||||||
|
signed_in = await client.post(
|
||||||
|
"api/v1/session",
|
||||||
|
json={"access_token": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
status = await client.get("api/v1/session")
|
||||||
|
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert signed_in.status_code == 200
|
||||||
|
assert status.status_code == 200
|
||||||
|
assert any(
|
||||||
|
"stackchain_session=" in value and "Path=/dashboard" in value
|
||||||
|
for value in signed_in.headers.get_list("set-cookie")
|
||||||
|
)
|
||||||
81
tests/test_dashboard_session_frontend.py
Normal file
81
tests/test_dashboard_session_frontend.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.views import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SESSION_JS = ROOT / "frontend" / "session.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_session_scenario(scenario: str) -> dict:
|
||||||
|
harness = f"""
|
||||||
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
|
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
|
||||||
|
const storage = {{
|
||||||
|
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||||
|
get length() {{ return this.values.size; }},
|
||||||
|
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
|
||||||
|
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
|
||||||
|
}};
|
||||||
|
const boundary = createSessionBoundary({{
|
||||||
|
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
|
||||||
|
origin: 'https://forge.example',
|
||||||
|
base: '/dashboard/',
|
||||||
|
fetchImpl: async (url, options = {{}}) => {{
|
||||||
|
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
|
||||||
|
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||||
|
}},
|
||||||
|
localStorage: storage,
|
||||||
|
sessionStorage: storage,
|
||||||
|
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
||||||
|
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||||
|
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||||
|
}});
|
||||||
|
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
||||||
|
result = run_session_scenario(
|
||||||
|
"""
|
||||||
|
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_out_clears_only_dashboard_private_device_state():
|
||||||
|
result = run_session_scenario(
|
||||||
|
"""
|
||||||
|
await boundary.signOut();
|
||||||
|
state.remaining = Array.from(storage.values.keys());
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
request = result["requests"][0]
|
||||||
|
assert request["url"] == "/dashboard/api/v1/session"
|
||||||
|
assert request["method"] == "DELETE"
|
||||||
|
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||||
|
assert "stackchain.private" in result["removed"]
|
||||||
|
assert result["remaining"] == ["gitea.preference"]
|
||||||
|
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||||
|
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||||
|
assert result["assigned"] == "/dashboard/login"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/session.js"></script>' in html
|
||||||
|
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||||
|
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||||
|
|
@ -70,10 +70,10 @@ async function dispatchSync(tag) {{
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_detail_defer_ships_in_a_new_shell_cache():
|
def test_operator_session_boundary_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v15" in source
|
assert "stackchain-dashboard-shell-v16" in source
|
||||||
|
|
||||||
|
|
||||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
|
|
@ -88,6 +88,14 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||||
assert result["backgroundFlushes"] == 1
|
assert result["backgroundFlushes"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -103,6 +111,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/manifest.webmanifest",
|
"/dashboard/manifest.webmanifest",
|
||||||
"/dashboard/static/icons/stackchain-192.png",
|
"/dashboard/static/icons/stackchain-192.png",
|
||||||
"/dashboard/static/icons/stackchain-512.png",
|
"/dashboard/static/icons/stackchain-512.png",
|
||||||
|
"/dashboard/static/session.js",
|
||||||
"/dashboard/static/markdown.js",
|
"/dashboard/static/markdown.js",
|
||||||
"/dashboard/static/commands.js",
|
"/dashboard/static/commands.js",
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user