diff --git a/AGENTS.md b/AGENTS.md index a4df616..bec7413 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,13 @@ numbers, which is exactly what "works in my headless run" cannot tell you. Extend it when the next geometry puzzle appears; the endpoint takes whatever fields `SnapReport` declares. +`?diag=1` also exposes **Capture diagnostic**. The uploaded PNG URL at +`/api/debug/captures/{id}` is deliberately public: its 72-bit id is the +handoff from a browser to an agent on a different machine. Capture upload and +metadata remain authenticated. Inspect only a URL the user explicitly shares, +then immediately `DELETE /api/debug/captures/{id}`. The server also expires +captures after 24 hours and caps the directory at 50 files. + ## Keep the two documents current This is a running system under continual change, not a build being executed, so diff --git a/app/api/captures.py b/app/api/captures.py index 158a2dd..a6bd844 100644 --- a/app/api/captures.py +++ b/app/api/captures.py @@ -57,3 +57,14 @@ def capture_path(capture_id: str, suffix: str) -> Path | None: return None path = CAPTURE_DIR / f"{capture_id}{suffix}" return path if path.is_file() else None + + +def delete_capture(capture_id: str) -> bool: + if not CAPTURE_ID.fullmatch(capture_id): + return False + image = CAPTURE_DIR / f"{capture_id}.png" + metadata = CAPTURE_DIR / f"{capture_id}.json" + if not image.is_file() and not metadata.is_file(): + return False + _remove(capture_id) + return True diff --git a/app/api/meta.py b/app/api/meta.py index 193c928..69be089 100644 --- a/app/api/meta.py +++ b/app/api/meta.py @@ -20,7 +20,7 @@ from app.api.deps import ( password_matches, token_matches, ) -from app.api.captures import capture_path +from app.api.captures import capture_path, delete_capture router = APIRouter(prefix="/api") @@ -66,3 +66,20 @@ def login(credentials: LoginRequest, request: Request, response: Response): @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) def logout(response: Response): response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict") + + +@router.get("/debug/captures/{capture_id}") +def get_debug_capture(capture_id: str): + """A short-lived diagnostic screenshot shared by its unguessable id.""" + path = capture_path(capture_id, ".png") + if path is None: + raise HTTPException(404, "Capture not found") + return FileResponse(path, media_type="image/png") + + +@router.delete("/debug/captures/{capture_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_debug_capture(capture_id: str): + """Erase a public diagnostic screenshot after inspection.""" + if not delete_capture(capture_id): + raise HTTPException(404, "Capture not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/api/routes.py b/app/api/routes.py index e65c3a4..99328c5 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -8,7 +8,6 @@ import uuid from datetime import date as Date from typing import Literal -from fastapi.responses import FileResponse from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from pydantic import BaseModel, Field @@ -314,22 +313,6 @@ def debug_snap(payload: SnapReport): return Response(status_code=204) -@router.get("/debug/captures/{capture_id}") -def get_debug_capture(capture_id: str): - """A diagnostic capture, behind the same auth as everything else. - - These are screenshots of somebody's screen. The id is 72 bits of entropy so - the URL is unguessable, but a capability URL still leaks through anything - that records URLs — proxy logs, browser history, a pasted link. Requiring a - session costs nothing: a browser already holds the cookie, and an API client - already sends the token. - """ - path = capture_path(capture_id, ".png") - if path is None: - raise HTTPException(404, "Capture not found") - return FileResponse(path, media_type="image/png") - - @router.get("/debug/captures/{capture_id}/meta") def get_debug_capture_metadata(capture_id: str): path = capture_path(capture_id, ".json") @@ -414,4 +397,3 @@ async def es_option_search( except ValueError as exc: raise HTTPException(502, str(exc)) from exc return result - diff --git a/docs/implementation.md b/docs/implementation.md index 31aaff7..2f1a9cd 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -683,28 +683,23 @@ intraday candles, but on the daily chart it made the SMA itself look like a staircase. MA line type is now timeframe-aware: stepped on intraday charts and simple point-to-point lines on `1d`, with both modes pinned by browser coverage. -### 2026-08-11 — diagnostic captures moved behind auth +### 2026-08-11 — diagnostic capture handoff -The capture endpoints were split: uploading required a token, retrieving did -not. That was deliberate — an unguessable id acting as a capability URL, with a -test asserting it — and the reasoning was sound: it lets someone debugging fetch -a capture without holding the chart password. +Captures are taken in a person's browser but often need inspection by an agent +on a different machine. Keeping image retrieval behind the browser's HttpOnly +session left that agent unable to see a supplied capture URL, while filesystem +access works only when it is attached to the production container. -Changed anyway, because of what a capture is. `getDisplayMedia` returns a -picture of somebody's screen, and `preferCurrentTab` is a preference rather than -a constraint, so a mis-click shares a different window. 72 bits of entropy stops -guessing, but a capability URL still escapes through everything that records -URLs: proxy and access logs, browser history, a link pasted into a chat. +The image URL is therefore public again, using its 72-bit capture id as the +explicit handoff capability. Capture creation and metadata retrieval remain +authenticated; metadata can contain drawing text and geometry not needed to +inspect the pixels. `DELETE /api/debug/captures/{id}` is public too, so an agent +can remove an inspected screenshot immediately. The existing 24-hour expiry and +50-capture cap remain the backstop. -Retrieval now uses the same dependency as the rest of the API, which already -accepts the session cookie — so a browser that is logged in needs nothing extra, -which was the requirement. An agent on the server reads the files directly from -the capture directory, and one working over HTTP presents the API token. Neither -path got harder, which is why the trade was worth making. - -Both handlers moved from `meta.py` to `routes.py`. `meta.py` is the deliberately -unauthenticated router — health, version, login and logout — and a screenshot -endpoint did not belong in that company. +The public image and cleanup handlers live in `meta.py`, the deliberately +unauthenticated router. Capture creation and metadata remain in `routes.py`, +behind the normal chart authentication. ### 2026-08-11 — alerts carry a number and a local time diff --git a/docs/vite_build.md b/docs/vite_build.md index ce7034f..2642f67 100644 --- a/docs/vite_build.md +++ b/docs/vite_build.md @@ -11,9 +11,10 @@ Today `static/index.html` loads Vue 3, Lightweight Charts 5.2.0 and Font Awesome ## The goal is not "more Vue" -The target is **a pinned, hashed, same-origin frontend** that we can grow -without unpkg and without a Python hasher. It is not a component split, not -TypeScript, not a router, and not leaving Coolify. +The target is **a pinned, hashed, minified, same-origin frontend** that we can +grow without unpkg and without a Python hasher. View-source today is the +entire app. After this, a casual reader should not get `app.js` back. It is +not a component split, not TypeScript, not a router, and not leaving Coolify. - `chart.js` stays a plain class. Vue still must not wrap chart or series objects in `ref()` / `reactive()`. @@ -135,10 +136,44 @@ Optional later, not part of the move: `vite` with `server.host: true` and `server.hmr` pointed at the machine the *browser* can see. Only worth it if the watch-and-refresh loop is actually painful. -`vite.config.js` needs nothing clever for the default path — `base: '/'`, +`vite.config.js` needs little for the default path — `base: '/'`, `build.outDir` set so FastAPI and the watcher agree (repo-root `dist/` or -`frontend/dist/`, pick one and use it everywhere). No `/api` proxy until -someone runs the Vite dev server. +`frontend/dist/`, pick one and use it everywhere). Production minify is a +requirement, not an option; see below. No `/api` proxy until someone runs +the Vite dev server. + +## Production minify + +Vite's production build already minifies JS and CSS with esbuild. Keep that +on. Do not set `build.minify: false` to "make debugging easier" — that is +what the source tree is for. + +```js +build: { + minify: 'esbuild', + sourcemap: false, + cssMinify: true, +} +``` + +**No source maps in what FastAPI serves.** A `.map` file next to the bundle +is the original source with a different URL. `sourcemap: false` is the +default; do not turn it on in the config that Coolify builds. Local +debugging reads `frontend/src/`, not a map shipped to the browser. + +**Do not put the source tree on the production image.** The multi-stage +`COPY . .` below would otherwise copy `frontend/src/` into the container. +Even unmounted, that is one Traefik mistake away from being public. The +final stage copies `dist/` only. `.dockerignore` must list `frontend/`. + +`vite build --watch` in compose is a production build in a loop, so local +and prod stay equally minified. That is what we want. Slower than HMR; +acceptable. + +This is a speed bump, not a lock. `window.__chart` remains a deliberate +debug handle and e2e depends on it — anyone who knows to open the console +still has the wrapper. Minify so View Source is not the codebase; do not +delete `__chart` to chase real secrecy. ## Production: Dockerfile, not nixpacks @@ -170,15 +205,21 @@ FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY . . +COPY main.py Procfile ./ +COPY app ./app COPY --from=frontend /src/dist /app/dist EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] ``` -Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/` and -`.env` never enter the build context. A missing ignore is how the Schwab -token or the drawing store gets baked into an image. +The final stage must not `COPY . .` once `frontend/` exists — that would +ship the unminified source next to the bundle. Copy the Python package and +`dist/` only. + +Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/`, +`.env` and `frontend/` never enter the *final* build context. A missing +ignore is how the Schwab token, the drawing store, or `App.vue` gets baked +into an image. Unchanged, and not Coolify's problem: @@ -276,6 +317,8 @@ is what builds it. There is no third path. - Do not keep `static/` as a fallback once `dist/` is the source of truth. - Do not add a root `package.json` before phase 1 is live. - Do not run `npm` from pytest or from the API container. +- Do not ship source maps, serve `frontend/`, or leave `static/` up once + `dist/` is live. Any of those undoes minify. ## Verify @@ -300,7 +343,9 @@ curl -fsS https://chart.amow.com/api/version ``` View-source on `/` should show `/assets/…` with a hash and no unpkg script -tags. A hard refresh on a tab that was open across the deploy should pick +tags. The JS behind that URL should be a single minified file with no +`.map`, and fetching `/frontend/src/App.vue` or `/static/app.js` should +404. A hard refresh on a tab that was open across the deploy should pick up the new JS without a `?v=` rewriter. ## When this is done diff --git a/tests/test_auth.py b/tests/test_auth.py index b094e92..efc8d59 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -174,7 +174,7 @@ def test_writes_are_protected(client): assert client("s3cret").post("/api/lines", json=payload).status_code == 401 -def test_a_capture_needs_auth_to_upload_and_to_retrieve( +def test_a_capture_needs_auth_to_upload_but_can_be_retrieved_and_deleted( client, tmp_path, monkeypatch ): monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures") @@ -202,19 +202,17 @@ def test_a_capture_needs_auth_to_upload_and_to_retrieve( assert response.status_code == 201 saved = response.json() assert saved["id"].startswith("c-") - # A capture is a picture of somebody's screen. The id is unguessable, but a - # capability URL still escapes through anything that records URLs — proxy - # logs, browser history, a link pasted into a chat. Retrieval is behind the - # same auth as the rest of the API, which costs a browser nothing because it - # already holds the session cookie. - assert probe.get(saved["url"]).status_code == 401 + # The screenshot URL is the intentional handoff from a browser to an agent + # on a different machine. Metadata remains private because it can contain + # the selected drawing's text and geometry. + assert probe.get(saved["url"]).content == image assert probe.get(saved["metadata_url"]).status_code == 401 - - assert probe.get(saved["url"], headers={"X-Chart-Token": "s3cret"}).content == image details = probe.get(saved["metadata_url"], headers={"X-Chart-Token": "s3cret"}).json() assert details["id"] == saved["id"] assert details["timeframe"] == "30m" assert details["viewport_width"] == 1440 + assert probe.delete(saved["url"]).status_code == 204 + assert probe.get(saved["url"]).status_code == 404 def test_a_capture_is_retrievable_with_the_browser_session_cookie(client, tmp_path, monkeypatch):