Compare commits

...
7 Commits
14 changed files with 113 additions and 45 deletions
+3 -3
View File
@@ -13,7 +13,7 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
@@ -72,8 +72,8 @@ async function loadUserInfo() {
// apiJson handles 401/403 with auth.iframe automatically: // apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request. // shows overlay iframe, waits for auth, retries the request.
const [validateData, userInfoData] = await Promise.all([ const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST' }), apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
apiJson('/auth/api/user-info', { method: 'GET' }) apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
]) ])
store.userInfo = userInfoData store.userInfo = userInfoData
store.ctx = validateData.ctx store.ctx = validateData.ctx
+2 -2
View File
@@ -13,7 +13,7 @@ import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson, SessionValidator } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7' import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
@@ -196,7 +196,7 @@ function orgUserCount(org) {
} }
async function loadUserInfo() { async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' }) const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
info.value = data info.value = data
updateThemeFromSession(data.ctx) updateThemeFromSession(data.ctx)
authenticated.value = true authenticated.value = true
+3 -2
View File
@@ -59,7 +59,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia' import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({ const status = reactive({
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
} }
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${result.exchange_code}` } headers: { 'Authorization': `Bearer ${result.exchange_code}` },
timeout: paskiaSettings.auth_ms,
}) })
} }
+4 -4
View File
@@ -58,7 +58,7 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia' import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue' import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav' import { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
@@ -147,7 +147,7 @@ async function fetchSettings() {
async function validateSession() { async function validateSession() {
try { try {
session.value = await fetchJson('/auth/api/validate', { method: 'POST' }) session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(session.value?.ctx) updateThemeFromSession(session.value?.ctx)
if (isAuthenticated.value && props.mode !== 'reauth') { if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden' currentView.value = 'forbidden'
@@ -198,7 +198,7 @@ async function logoutUser() {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
await fetchJson('/auth/api/logout', { method: 'POST' }) await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
session.value = null session.value = null
currentView.value = 'login' currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000) showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
@@ -220,7 +220,7 @@ async function exchangeCode(result) {
throw new Error('Authentication response missing exchange_code') throw new Error('Authentication response missing exchange_code')
} }
return await fetchJson('/auth/api/set-session', { return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` } method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
}) })
} }
+5 -4
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey' import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia' import { apiJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`}, headers: {'Authorization': `Bearer ${result.session_token}`},
timeout: paskiaSettings.auth_ms,
}) })
}, },
async register() { async register() {
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async loadUserInfo() { async loadUserInfo() {
try { try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' }) this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(this.userInfo) updateThemeFromSession(this.userInfo)
console.log('User info loaded:', this.userInfo) console.log('User info loaded:', this.userInfo)
} catch (error) { } catch (error) {
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logout() { async logout() {
try { try {
await apiJson('/auth/api/logout', {method: 'POST'}) await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logoutEverywhere() { async logoutEverywhere() {
try { try {
await apiJson('/auth/api/user/logout-all', {method: 'POST'}) await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
+24
View File
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
The JSON variants set headers automatically, with body and response in JSON. The JSON variants set headers automatically, with body and response in JSON.
### Timeout Settings
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
```js
import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
settings.fetch_ms = 10000
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
```
You can still override timeout per request:
```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
```
### Authentication Overlay ### Authentication Overlay
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request. Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paskia", "name": "paskia",
"version": "1.1.0", "version": "1.4.0",
"description": "Paskia authentication utilities for JavaScript", "description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko", "author": "Leo Vasanko",
"license": "Unlicense", "license": "Unlicense",
+2 -3
View File
@@ -1,9 +1,8 @@
import { showAuthIframe, AuthCancelledError } from './overlay' import { showAuthIframe, AuthCancelledError } from './overlay'
import settings from './settings'
export { AuthCancelledError } export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit { export interface ApiFetchOptions extends RequestInit {
timeout?: number timeout?: number
} }
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
} }
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> { export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options const { timeout = settings.fetch_ms, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include' fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) { while (true) {
+2
View File
@@ -12,6 +12,8 @@ export {
export type { ApiFetchOptions, FetchJsonOptions } from './fetch' export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export { default as settings } from './settings'
export { export {
holdGlobalBackdrop, holdGlobalBackdrop,
releaseGlobalBackdrop, releaseGlobalBackdrop,
+6
View File
@@ -0,0 +1,6 @@
export default {
fetch_ms: 10000,
auth_ms: 1000,
poll_ms: 60000,
idle_ms: 300000,
}
+4 -6
View File
@@ -1,7 +1,5 @@
import { apiJson } from './fetch' import { apiJson } from './fetch'
import settings from './settings'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator { export class SessionValidator {
private userUuidGetter: () => string | undefined private userUuidGetter: () => string | undefined
@@ -19,12 +17,12 @@ export class SessionValidator {
resetIdleTimer(): void { resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer) if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling() if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT) this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
} }
async validate(): Promise<void> { async validate(): Promise<void> {
try { try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' }) const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
const newUuid = data.ctx?.user?.uuid const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) { if (newUuid !== this.userUuidGetter()) {
window.location.reload() window.location.reload()
@@ -40,7 +38,7 @@ export class SessionValidator {
startPolling(): void { startPolling(): void {
if (this.active) return if (this.active) return
this.active = true this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL) this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
} }
stopPolling(): void { stopPolling(): void {
+21
View File
@@ -21,6 +21,7 @@ from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey from paskia.globals import passkey as global_passkey
from paskia.util.crypto import hash_secret
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import ( from paskia.util.apistructs import (
ApiCheckUserResponse, ApiCheckUserResponse,
@@ -56,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
_REFRESH_INTERVAL = timedelta(minutes=5) _REFRESH_INTERVAL = timedelta(minutes=5)
def _set_log_extra(request: Request, *parts: str) -> None:
values = [part for part in parts if part]
if values:
request.state.log_extra = " ".join(values)
@app.exception_handler(ValueError) @app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError): async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)}) return JSONResponse(status_code=400, content={"detail": str(exc)})
@@ -110,6 +117,7 @@ async def validate_token(
) )
session.set_session_cookie(response, auth) session.set_session_cookie(response, auth)
renewed = True renewed = True
_set_log_extra(request, ctx.session.key)
return MsgspecResponse( return MsgspecResponse(
ApiValidateResponse( ApiValidateResponse(
valid=True, valid=True,
@@ -195,6 +203,15 @@ async def forward_authentication(
- Otherwise: JSON response with error details and an `iframe` field - Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication. pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
""" """
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
forwarded = (
f"{forwarded_method} {forwarded_uri}"
if forwarded_method and forwarded_uri
else ""
)
_set_log_extra(request, forwarded)
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -202,6 +219,7 @@ async def forward_authentication(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
) )
_set_log_extra(request, forwarded, ctx.session.key)
# Build permission scopes for Remote-Groups header # Build permission scopes for Remote-Groups header
role_permissions = ( role_permissions = (
{p.scope for p in ctx.permissions} if ctx.permissions else set() {p.scope for p in ctx.permissions} if ctx.permissions else set()
@@ -276,6 +294,8 @@ async def api_user_info(
clear_session=True, clear_session=True,
) )
_set_log_extra(request, ctx.session.key)
return MsgspecResponse( return MsgspecResponse(
await userinfo.build_user_info( await userinfo.build_user_info(
user_uuid=ctx.user.uuid, user_uuid=ctx.user.uuid,
@@ -351,5 +371,6 @@ async def api_set_session(
if not ctx: if not ctx:
raise HTTPException(401, f"Session not found on {host}") raise HTTPException(401, f"Session not found on {host}")
_set_log_extra(request, hash_secret("cookie", secret))
session.set_session_cookie(response, secret) session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)} return {"status": "ok", "user": str(ctx.user.uuid)}
+32 -16
View File
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey) _HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (white) _PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey) _TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow) _WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow) _WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey) _WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red) _AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
_AUTHZ_USER = "\033[1;34m" # User info (light blue) _AUTHZ_USER = "\033[1;34m" # User info (light blue)
@@ -112,7 +112,13 @@ def method_color(method: str) -> str:
def format_access_log( def format_access_log(
client: str, status: int, method: str, host: str, path: str, duration_ms: float client: str,
status: int,
method: str,
host: str,
path: str,
duration_ms: float,
extra: str = "",
) -> str: ) -> str:
"""Format access log line with colors and aligned fields.""" """Format access log line with colors and aligned fields."""
# Format components with fixed widths for alignment # Format components with fixed widths for alignment
@@ -126,8 +132,9 @@ def format_access_log(
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
# Format: "IP STATUS METHOD host path TIMING" # Format: "IP STATUS METHOD host path [extra] TIMING"
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}" extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
# WebSocket connection counter (mod 100) # WebSocket connection counter (mod 100)
@@ -152,20 +159,21 @@ def log_ws_open(ws) -> int:
origin = ws.headers.get("origin") origin = ws.headers.get("origin")
ip = format_client_ip(client).ljust(19) ip = format_client_ip(client).ljust(19)
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) # ID right-aligned like status codes (3 chars), emoji formatted like method
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
# Determine if origin should be shown (omit when same as host) # Determine if origin should be shown (omit when same as host)
# Origin header includes scheme (e.g., "https://example.com"), compare host part # Origin header includes scheme (e.g., "https://example.com"), compare host part
origin_host = origin.split("://", 1)[-1] if origin else None origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host show_origin = origin_host and origin_host != host
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else "" origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}") logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
return ws_id return ws_id
@@ -191,21 +199,25 @@ WS_CLOSE_CODES = {
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None: def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status.""" """Log WebSocket connection close with duration and status."""
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) # ID right-aligned like status codes (3 chars), "closed" formatted like method
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
# Pad within the dim color to keep full width in color (8 display chars)
closed_str = f"{_TIMING}closed {_RESET}"
timing = f"{duration * 1000:.0f}ms" timing = f"{duration * 1000:.0f}ms"
# Convert close code to status text # Convert close code to status text
if close_code is None: if close_code is None:
status = "closed" code = "----"
status = "unknown"
else: else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}") status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
# 🔌 aligned with status, ID aligned with method # Status code and text in normal color, not dim
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}" status_str = f"{code} {status}"
status_str = f"{_WS_STATUS}{status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}" timing_str = f"{_TIMING}{timing}{_RESET}"
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}") logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
def log_permission_denied( def log_permission_denied(
@@ -244,7 +256,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
path = f"{path}?{request.url.query}" path = f"{path}?{request.url.query}"
status = response.status_code status = response.status_code
line = format_access_log(client, status, method, host, path, duration_ms) extra = getattr(request.state, "log_extra", "")
line = format_access_log(
client, status, method, host, path, duration_ms, extra=extra
)
logger.info(line) logger.info(line)
return response return response
+4 -4
View File
@@ -19,8 +19,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
# ANSI color codes # ANSI color codes
RESET = "\033[0m" RESET = "\033[0m"
YELLOW = "\033[33m" # Dark yellow YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
BRIGHT_YELLOW = "\033[93m" # Bright yellow BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
BRIGHT_WHITE = "\033[1;37m" # Bold bright white BRIGHT_WHITE = "\033[1;37m" # Bold bright white
@@ -50,8 +50,8 @@ def bottom() -> str:
def print_startup_config(runtime: RuntimeConfig) -> None: def print_startup_config(runtime: RuntimeConfig) -> None:
"""Print server configuration on startup.""" """Print server configuration on startup."""
# Key graphic with yellow shading (bright for highlights, dark for body) # Key graphic with yellow shading (bright for highlights, dark for body)
y = YELLOW # Dark yellow for main body y = YELLOW # Bright golden yellow for main body
b = BRIGHT_YELLOW # Bright yellow for highlights/edges b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
w = BRIGHT_WHITE # Bold white for URL w = BRIGHT_WHITE # Bold white for URL
r = RESET r = RESET