A quiet celestial focus room built around a complete 50+10 rhythm.
AethelDesk is a shared celestial dashboard for synchronized 50-minute focus sessions and fixed 10-minute recovery. Run it on your Mac, open the same PIN-protected room on your iPad, and keep the timer, sky, and completion ritual in sync.
The same 50+10 focus room moves naturally through the day and across three distinct environments while keeping the timer and shared controls consistent.
| Moonlit coast | City after dark | Alpine forest |
|---|---|---|
![]() |
![]() |
![]() |
Every normal completion moves immediately into the same ten-minute recovery ritual. The code-only Three.js Aethel Astrarium lights one of four amber nodes, persists across scene changes and reconnects, and never replays its reveal from a restored snapshot.
AethelDesk keeps room coordination server owned while the browser client stays lightweight:
- Frontend: Vite + vanilla ES modules with a shared Three.js renderer.
package.jsonandpackage-lock.jsonare the authoritative frontend dependency files. - Backend: FastAPI serves
/,/room/{room_id}, REST room APIs, WebSockets,/health, Vite hashed assets at/assets/*whenfrontend/dist/assetsexists, and source frontend files as a no-cache fallback. - Static cache policy: route-owned HTML uses
Cache-Control: no-cache, Vite hashed/assets/*usespublic, max-age=31536000, immutable, and root static fallback usesno-cache. - Room state: Redis stores the canonical JSON room state and metadata with keys such as
aetheldesk:room:{ROOM_ID}:stateandaetheldesk:room:{ROOM_ID}:meta. - Sync: Redis Pub/Sub publishes full-state room snapshots on
aetheldesk:room:{ROOM_ID}:events; each worker fans updates out only to its own local WebSockets. - Scheduler: every worker may run the scheduler, but Redis
TIMEleases and state revisions fence each timer commit. A running focus or break keeps advancing through a temporary browser disconnect without double-decrementing. - Access: rooms use a PIN at create or join time. Atomic standalone-Redis operations serialize room creation, bind opaque token hashes to one room generation, and never store plaintext PINs.
Redis state is restart-tolerant ephemeral room state when Docker AOF is enabled with appendfsync everysec. It can recover current active room state after a restart, but it is not permanent history, audit storage, analytics storage, or replay storage.
See docs/architecture/contracts.md for the route, WebSocket, Redis, state, and frontend storage contracts that later work must preserve.
| Feature | Details |
|---|---|
| Celestial ambience | Backend calculates the sun state and broadcasts updates to connected clients. |
| Shared rooms | /room/{room_id} serves the Vite room page for the same shared session. |
| Room PIN access | Create and join flows require a PIN and return an opaque session token. |
| 50+10 focus cycle | Starts at 50 minutes by default and follows every normal completion with the same fixed 10-minute break. |
| Completion ritual | A monotonic reward lights one Aethel Astrarium node, then offers optional recovery and next-intent choices without stopping the shared timer. |
| Three environments | Switch between the unified coastal sky, city, and forest; the retired standalone beach preference migrates to the coast. |
| Touch-friendly controls | During focus and recovery, surrounding controls recede gently and return to full contrast on pointer or keyboard interaction. |
| Korean-primary UI | Interactive copy and status text use Korean-first wording while keeping AethelDesk, PIN, storage keys, and API fields stable. |
Run commands from the repository root unless a row says otherwise.
| Command | Use | Expected gate |
|---|---|---|
uv sync --frozen |
Install locked Python runtime and dev dependencies from pyproject.toml and uv.lock. |
Required for local dev and CI before Python gates. |
uv run ruff format --check . |
Check Python formatting policy. | CI formatting gate. |
uv run ruff check . |
Run Python lint checks. | CI lint gate. |
uv run pyright |
Run Python type checks. | CI type gate. |
uv run pytest -q |
Run the default non-e2e Python suite. | Required local and CI test gate. |
uv run playwright install chromium |
Install the Chromium browser for Playwright if missing. | Setup step for browser e2e. |
uv run pytest tests/e2e -m e2e --browser chromium -q |
Run Playwright browser e2e. | CI e2e gate and local regression gate for user-visible flows. |
npm ci |
Install locked frontend dependencies from package-lock.json. |
Docker frontend build stage and clean frontend setup. |
npm run dev |
Start the local Vite development server. | Loopback frontend dev with backend proxy; not a LAN deployment. |
npm run test:frontend |
Run atmosphere, scene-manager, storage, reward, rest-ritual, and fallback unit tests. | Required frontend behavior gate. |
npm run build |
Build production Vite assets into frontend/dist. |
Required local and CI frontend gate. |
docker compose up --build --detach |
Build and start the app plus Redis. | Docker self-host setup and CI Compose health gate where Docker exists. |
docker compose ps |
Check Compose service health. | App and Redis should be healthy after startup. |
curl -fsS "http://$(docker compose port app 8000)/health" |
Check app health. | Returns success when Redis is required and reachable. |
docker compose exec redis redis-cli CONFIG GET appendonly appendfsync |
Confirm Redis AOF policy. | Reports appendonly yes and appendfsync everysec. |
If this host does not have docker, do not treat local Compose health as passed. CI still runs docker compose up --build --detach and /health in the Playwright e2e job.
Install the locked Python environment:
uv sync --frozenRun the backend for this computer only, without Docker:
AETHELDESK_ENV=test uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000 \
--no-proxy-headers --ws-max-size 4096 --ws-max-queue 4 \
--limit-concurrency 1024 --timeout-keep-alive 5Open http://127.0.0.1:8000. Plain HTTP/WS is permitted only when both the actual peer and the requested host are loopback. Do not change this command to 0.0.0.0 for a second device; use the HTTPS deployment below instead.
AETHELDESK_ENV=test uses bounded in-memory room, PIN, token, and connection controls instead of Redis. It is a single-process test/development mode, not a production or cross-worker datastore. Outside test mode, Redis and a real AETHELDESK_SECRET_KEY are mandatory. All supported Uvicorn commands use --no-proxy-headers: only application middleware may interpret forwarded headers.
Install frontend dependencies from the lockfile:
npm ciStart Vite:
npm run devThe Vite dev server uses vite.config.js with root: "frontend". It proxies /api to http://127.0.0.1:8000 and /ws to ws://127.0.0.1:8000, including WebSocket upgrade support for /ws. Start the FastAPI backend on port 8000 before using API or room sync through Vite.
Build production assets with:
npm run buildnpm run build first compiles the local Tailwind stylesheet (npm run build:css → frontend/tailwind.css) and then runs Vite. The build writes to frontend/dist. FastAPI serves hashed assets from frontend/dist/assets when present. Do not commit generated frontend/dist unless a release process explicitly asks for it.
The frontend is fully self-contained: Tailwind is compiled locally and the Inter/Instrument Serif fonts are self-hosted under frontend/fonts/ (via frontend/fonts.css). The current focus room has no YouTube, playlist, scene-audio, or third-party runtime request. Network devices still require HTTPS/WSS, including on a private LAN.
Docker Compose runs the FastAPI app and Redis, publishing the backend only on 127.0.0.1:
cp .env.example .envFrom the repository root, edit .env and replace AETHELDESK_SECRET_KEY=replace-with-long-random-secret with a generated secret, for example the output of openssl rand -hex 32. Keep .env private. Start the stack:
docker compose up --build --detachCheck app health and Redis AOF configuration:
docker compose ps
curl -fsS "http://$(docker compose port app 8000)/health"
docker compose exec redis redis-cli CONFIG GET appendonly appendfsyncThe Docker image builds frontend assets with npm ci, runs the final Python app as a non-root appuser, persists Redis in the redis-data volume, and checks Redis health with PING, appendonly yes, and appendfsync everysec. Redis config lives in docker/redis/redis.conf.
GET /health is a nonsecret HTTP exception for health checks. A successful health check does not mean the browser UI can use the published HTTP port: Docker may present a non-loopback NAT peer to the app, which correctly rejects insecure UI/API traffic. Use the HTTPS proxy for the Compose UI.
Required and useful environment variables:
| Variable | Default | Purpose |
|---|---|---|
APP_PORT |
8000 |
Loopback host port used by Docker Compose; match the TLS proxy upstream port. |
REDIS_URL |
redis://redis:6379/0 |
Redis connection URL for the app container. |
ROOM_TTL_SECONDS |
300 |
Disconnected idle/paused room expiry window; activity may refresh it only within the room hard lifetime. |
ROOM_TICK_LOCK_SECONDS |
2 |
Redis lease-marker TTL for fenced per-room scheduler ticks. |
AETHELDESK_ENV |
docker in .env.example |
Runtime mode. Use test only for local/test runs. |
AETHELDESK_SECRET_KEY |
none | Required outside pytest/test mode for Room PIN tokens. |
AETHELDESK_TRUST_PROXY |
0 |
Enable forwarded scheme/client identity only with a verified proxy peer allowlist. |
AETHELDESK_TRUSTED_PROXY_IPS |
empty | Comma-separated exact IPs or narrowly scoped CIDRs for actual proxy peers visible to the app. Never use a whole address family, shared untrusted network, or arbitrary forwarded client IPs. |
Install Docker Compose and host-managed nginx, and configure DNS so each device resolves your hostname to this host. Expose only the intended HTTPS port 443 to those devices. Use a certificate trusted by every browser device. Certificate issuance, trust installation, and renewal are operator-managed; this repository does not automate them.
- Start the loopback-only Compose backend above. Do not publish Redis or change the app mapping to
0.0.0.0. - Adapt
docker/nginx/aetheldesk.conf.examplefor an externally managed nginx on the Docker host. It belongs inside nginx'shttp {}configuration. Replace the hostname, certificate/key paths, and the upstream port ifAPP_PORTis not8000. - Through the configured TLS proxy, request
https://YOUR_HOSTNAME/health?proxy-peer-check=1, then inspectdocker compose logs --since 1m app. With the required--no-proxy-headers, the source address on that request's Uvicorn access-log line is the actual app-visible TCP peer. The health exception works before proxy trust is enabled. For a native app this can be127.0.0.1; Docker NAT may expose a different address. SetAETHELDESK_TRUST_PROXY=1andAETHELDESK_TRUSTED_PROXY_IPSto that verified exact address, then rundocker compose up --detach --force-recreate app. Do not guess a broad subnet. If untrusted processes share that address, isolate the proxy/backend network or use a dedicated trusted host rather than allowlisting the shared source. - Validate the adapted configuration with
nginx -tbefore an operator reload. Openhttps://YOUR_HOSTNAMEfrom each device, never the backend port or an HTTP URL. The example rejects HTTP rather than pretending a redirect can protect a PIN already sent over HTTP. - Verify valid certificate trust,
wss://room synchronization, rejection of cross-origin API/WS requests, rejection of spoofed forwarding headers from an untrusted peer, and inaccessibility of the backend port from another device. Check accepted and rejected WebSocket requests for token-free logs at every logging layer. These are deployment checks, not results established by static tests.
The example overwrites forwarding headers and preserves the public Host, passes the WebSocket upgrade explicitly, caps bodies at 4096 bytes, and streams request bodies so the application can enforce its absolute five-second deadline. Its safe access format excludes queries, credentials, bodies, and referrers; native per-vhost error logging is disabled because it can include raw URLs. If operators need error logs, supply redaction before storage rather than enabling an unfiltered request log. Review any additional ingress/container/central logging independently. See the nginx WebSocket proxying, body-limit, and logging references.
The security admission contract specifies exact budgets and response behavior. PIN work, room allocation, token issuance, active sockets, and messages are bounded in both Redis and local/test modes; source quotas can affect devices sharing a NAT address and are not an account identity or a distributed-denial-of-service guarantee.
Room state expires after at most eight hours. A browser token has its own two-hour hard lifetime and fifteen-minute inactivity deadline; another token does not extend those deadlines. An authenticated open socket counts as activity for its own token. Explicit Leave revokes that token before completing navigation and clears this tab's credential. If revocation fails, the page stays open with a retry message instead of claiming logout. Ordinary reload/reconnect remains a separate flow. WebSockets send the bearer in a bounded first authentication frame, never in a URL.
For this security update, stop all old workers, deploy the rebuilt frontend and new backend together, and start only the new workers. Existing tokens are not migrated, and old rooms without hard-lifetime metadata fail closed. Create a new room code and have participants join it; old ephemeral keys are left to expire naturally, not deleted by this patch. Reusing an old room code requires its old keys to expire first. Do not run mixed old/new workers.
| File | Status |
|---|---|
pyproject.toml |
Authoritative Python project metadata, runtime dependencies, dev group, Ruff config, and Pyright config. |
uv.lock |
Authoritative locked Python dependency graph. |
requirements.txt |
Exported compatibility file for Docker image installation. Keep it aligned with pyproject.toml, but do not treat it as the source of truth. |
requirements-dev.txt |
Exported compatibility file for older local scripts. Keep it aligned when Python dev dependencies change, but do not treat it as authoritative. |
package.json |
Authoritative frontend scripts, Three.js runtime dependency, and Vite/Tailwind development dependencies. |
package-lock.json |
Authoritative locked frontend dependency graph. |
frontend/dist/ |
Generated Vite production output. Build with npm run build; do not edit by hand. |
.omo/evidence/ |
Local task evidence artifacts. Append evidence during delegated tasks, but do not commit these files unless the orchestrator says to. |
CI and local release checks should cover these gates:
uv sync --frozenuv run ruff format --check .uv run ruff check .uv run pyrightuv run pytest -quv run playwright install chromiumuv run pytest tests/e2e -m e2e --browser chromium -qnpm cinpm run test:frontendnpm run build- Docker Compose build, app health, and Redis AOF health where Docker is installed
For delegated tasks, save concise command output or summaries under .omo/evidence/task-{N}-{slug}.txt. When a gate cannot run because the host lacks a tool, record the exact blocker and do not mark the gate as passed.
AethelDesk is a Vite + vanilla ES module frontend with a FastAPI backend. The security-remediation decision authorizes the limited TLS-proxy example and transport controls documented above. React, Vue, Svelte, TypeScript, Webpack, SQL/accounts/analytics, Redis Streams, Celery, Kubernetes, and TLS automation remain out of scope unless separately approved.
Redis remains mandatory outside explicit test mode and for all cross-worker behavior. The in-memory path is restricted to pytest or AETHELDESK_ENV=test.
Korean-primary copy is the UI policy. Interactive controls, validation errors, live regions, and connection or location status should use Korean-first wording. Keep the brand name AethelDesk, the security acronym PIN, storage keys, API fields, and route names stable. See docs/ux/audit.md for the current UX contract and historical audit.
External frontend dependency policy:
- The current room intentionally ships without music, playlists, or scene ambient audio. A later first-party audio experience needs its own approved state and storage contract.
- Frontend runtime assets stay local and are bundled through Vite.
- Inter and Instrument Serif remain self-hosted under
frontend/fonts/.
Still out of scope for this modernization:
- OAuth, user accounts, profiles, invite systems, admin dashboards, or analytics.
- SQL history, PostgreSQL or Postgres storage, permanent audit logs, or permanent room history.
- Redis Streams replay, Celery, CRDTs, distributed queues, or event replay storage.
- Kubernetes, managed cloud automation, TLS automation, and proxy infrastructure beyond the approved operator-managed example.
Create and join both require a PIN of 4-64 characters. Room ids are restricted to [A-Z0-9] after normalization, so they cannot inject extra Redis key segments. On success, the frontend stores the opaque token in sessionStorage under room_token:{ROOM_ID} and sends it in the first WebSocket authentication frame, never the URL. Tokens are scoped to the verified room generation; recreating the same room id invalidates older credentials. Live-session, source, and issuance quotas return HTTP 429 instead of evicting a valid session; see the admission contract.
Plaintext PIN values are sent only with the create or join request. They are not stored in Redis, room state, WebSocket payloads, sessionStorage, or localStorage after the request completes. Authentication failures use generic responses, including the Korean room error 입장할 수 없습니다, so the UI does not reveal whether a room exists.
Default Python tests exclude browser E2E and real-infrastructure integration through pytest.ini:
uv run pytest -qInstall Chromium for Playwright, then run the browser suite:
uv run playwright install chromium
uv run pytest tests/e2e -m e2e --browser chromium -qRun isolated real Redis, two-server, TLS, admission, and revocation checks when redis-server and openssl are installed:
uv run pytest tests/integration -m integration -qThese tests start their own loopback Redis and servers; they do not connect to an existing datastore or establish acceptance of an operator's nginx/Compose deployment.
Run the frontend production build gate:
npm run buildUse the HTTPS deployment runbook and its nginx example as one coordinated transport contract. Do not enable proxy trust from a boolean alone, omit the actual-peer allowlist, or re-enable Uvicorn proxy-header rewriting. Query-token clients must update/rejoin; do not add a compatibility route that accepts bearer tokens in URLs.
| Control | Behavior |
|---|---|
| Time slider | Drag to override sun position; double-click to return to real time. |
| Focus button | Starts the shared focus countdown; 25/50-minute chips choose the duration without starting it. |
| Timer controls | Pause, resume, cancel, or skip the fixed 10-minute break; none of these actions mints an extra reward. |
| Rest ritual | Optional recovery and next-intent choices guide the break without changing the authoritative timer. |
| Scene picker | Switches among the unified coastal sky, city, and forest. |
| Focus-session UI | Surrounding controls dim during focus or recovery and return to full contrast when hovered or keyboard-focused. |
| Symptom | Check |
|---|---|
npm run dev loads the page but API calls fail |
Start FastAPI on 127.0.0.1:8000; Vite proxies /api to that backend. |
| Room sync fails through Vite dev server | Confirm the backend is on port 8000 and the Vite /ws proxy is active. Browser WebSockets should connect through query-free /ws/{ROOM_ID} and send the authentication frame first. |
| Compose health succeeds but HTTP UI/API returns 403 | Docker NAT is not a loopback peer. Use the TLS proxy, set its exact app-visible peer allowlist, and preserve the public Host; do not disable transport enforcement. |
| Room access expires or creation/join is throttled | Review room/token hard lifetimes and shared-source quotas in the admission contract. Rejoin with the PIN after token expiry; do not bypass quotas by spoofing headers. |
| Built pages load without JS | Run npm run build, confirm frontend/dist/assets exists, and restart FastAPI or Docker so the new assets are served. |
/assets/* returns 404 in production |
The Vite build output is missing from frontend/dist; rebuild locally or check the Docker frontend build stage. |
/health returns 503 or rooms cannot sync |
Redis is unavailable. Check the Redis container, REDIS_URL, and docker compose ps. |
| Redis health never becomes healthy in Compose | Check docker/redis/redis.conf and docker compose exec redis redis-cli CONFIG GET appendonly appendfsync; expected values are yes and everysec. |
Startup fails with missing AETHELDESK_SECRET_KEY |
Set a real secret in .env for Docker or production. Only pytest/test mode can use the built-in test secret. |
| Playwright says Chromium is missing | Run uv run playwright install chromium. If the host warns about missing system libraries, install the OS packages named by Playwright. |
uv sync --frozen fails |
The lockfile is out of date or uv.lock does not match pyproject.toml. Update dependencies intentionally, then refresh the lockfile. |
Docker commands fail with docker: command not found |
Docker is not installed on this host. Record the blocker as evidence and do not claim Compose passed. |
The app is intentionally small. The current stack is FastAPI, Redis, Vite, and vanilla ES modules:
aetheldesk/
|-- backend/
| |-- frontend_routes.py
| |-- main.py
| |-- redis_contract.py
| |-- redis_scripts.py
| |-- room_routes.py
| |-- room_service.py
| |-- room_store.py
| |-- scheduler.py
| |-- state.py
| `-- websocket_handler.py
|-- frontend/
| |-- app.js
| |-- lobby.html
| |-- lobby.js
| |-- room.html
| |-- scenes.js
| `-- src/
|-- tests/
| |-- e2e/
| |-- test_backend_routes.py
| |-- js/
| `-- test_websocket_redis.py
|-- docker/redis/redis.conf
|-- docker-compose.yml
|-- Dockerfile
|-- package.json
|-- package-lock.json
|-- pyproject.toml
`-- uv.lock
Contributions are welcome. To keep the project small and predictable:
- Open an issue first for anything beyond a small fix, so scope can be agreed before code.
- Fork, branch from
main, and keep changes focused. - Match the approved scope: FastAPI, Redis, Vite, vanilla JavaScript, and no new heavyweight runtime dependencies without agreement.
- Add or update tests for behavior changes and make sure the relevant suite passes.
- Run the command matrix gates that match your change.
- Open a pull request describing the change and how you verified it.
Please keep proposals within the project's intent. See Governance For Modernization before suggesting larger systems.
Released under the MIT License. You are free to use, modify, and distribute it, including for commercial purposes, provided the copyright and license notice are retained.




