diagnostic capture feature

This commit is contained in:
Chris Amow 2026-08-11 16:08:32 -05:00
parent ff1b9982d1
commit c653e65d5b
9 changed files with 324 additions and 12 deletions

59
app/api/captures.py Normal file
View file

@ -0,0 +1,59 @@
import json
import os
import re
import secrets
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
CAPTURE_DIR = Path(tempfile.gettempdir()) / "chart-captures"
CAPTURE_TTL_SECONDS = 24 * 60 * 60
CAPTURE_LIMIT = 50
CAPTURE_MAX_BYTES = 10 * 1024 * 1024
CAPTURE_ID = re.compile(r"^c-[A-Za-z0-9_-]{12}$")
def _remove(capture_id: str) -> None:
for suffix in (".png", ".json"):
(CAPTURE_DIR / f"{capture_id}{suffix}").unlink(missing_ok=True)
def cleanup_captures(now: float | None = None) -> None:
current = time.time() if now is None else now
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
for image in images:
if current - image.stat().st_mtime > CAPTURE_TTL_SECONDS:
_remove(image.stem)
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
for image in images[: max(0, len(images) - CAPTURE_LIMIT + 1)]:
_remove(image.stem)
def save_capture(image: bytes, metadata: dict) -> tuple[str, dict]:
if not image.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError("Capture is not a PNG")
if len(image) > CAPTURE_MAX_BYTES:
raise ValueError("Capture exceeds 10 MB")
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
cleanup_captures()
capture_id = f"c-{secrets.token_urlsafe(9)}"
details = {
**metadata,
"id": capture_id,
"received_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"commit": os.environ.get("SOURCE_COMMIT", "dev"),
"bytes": len(image),
}
image_path = CAPTURE_DIR / f"{capture_id}.png"
metadata_path = CAPTURE_DIR / f"{capture_id}.json"
image_path.write_bytes(image)
metadata_path.write_text(json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return capture_id, details
def capture_path(capture_id: str, suffix: str) -> Path | None:
if not CAPTURE_ID.fullmatch(capture_id):
return None
path = CAPTURE_DIR / f"{capture_id}{suffix}"
return path if path.is_file() else None

View file

@ -5,9 +5,11 @@ the browser needs /api/login before it has a session, so these routes stay
outside the protected API router.
"""
import os
import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.api.deps import (
@ -18,6 +20,7 @@ from app.api.deps import (
password_matches,
token_matches,
)
from app.api.captures import capture_path
router = APIRouter(prefix="/api")
@ -40,6 +43,22 @@ def version():
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
@router.get("/debug/captures/{capture_id}")
def get_debug_capture(capture_id: str):
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")
if path is None:
raise HTTPException(404, "Capture not found")
return json.loads(path.read_text(encoding="utf-8"))
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(credentials: LoginRequest, request: Request, response: Response):
presented_token = request.headers.get("x-chart-token", "")

View file

@ -1,3 +1,5 @@
import base64
import json
import logging
import time
import uuid
@ -9,6 +11,7 @@ from app.bars.models import Timeframe
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.api.deps import require_token
from app.api.captures import CAPTURE_MAX_BYTES, save_capture
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose.
@ -17,6 +20,42 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
@router.post("/debug/captures", status_code=201)
async def create_debug_capture(request: Request):
try:
content_length = int(request.headers.get("content-length", "0") or 0)
except ValueError:
raise HTTPException(400, "Invalid Content-Length") from None
if content_length > CAPTURE_MAX_BYTES:
raise HTTPException(413, "Capture exceeds 10 MB")
if request.headers.get("content-type", "").split(";", 1)[0] != "image/png":
raise HTTPException(415, "Diagnostic capture must be image/png")
try:
encoded = request.headers.get("x-capture-metadata", "")
metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {}
if not isinstance(metadata, dict):
raise ValueError
except (ValueError, json.JSONDecodeError):
raise HTTPException(400, "Invalid capture metadata") from None
image = await request.body()
try:
capture_id, details = save_capture(image, metadata)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
logger.warning(
"CAPTUREDBG id=%s tf=%s viewport=%sx%s",
capture_id,
details.get("timeframe"),
details.get("viewport_width"),
details.get("viewport_height"),
)
return {
"id": capture_id,
"url": f"/api/debug/captures/{capture_id}",
"metadata_url": f"/api/debug/captures/{capture_id}/meta",
}
class LineCreate(BaseModel):
tf: Timeframe
side: Side

View file

@ -110,8 +110,8 @@ createApp({
const timeframe = ref(prefs.value.base_tf || '1m');
const levels = ref([]);
const clusters = ref([]);
const alerts = ref([]);
let alertSequence = 0;
const events = ref([]);
let eventSequence = 0;
// Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null);
const drawName = ref('');
@ -125,6 +125,8 @@ createApp({
const comments = ref([]);
const drawingFilter = ref('');
const drawingKind = ref('all');
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false);
const snap = ref(true);
const selectedLine = ref(null);
const selectedLines = ref([]);
@ -191,6 +193,13 @@ createApp({
});
});
function addEvent(message, kind = 'info', url = null) {
events.value = [
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url },
...events.value,
].slice(0, 20);
}
async function refreshStatus() {
const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json();
@ -238,10 +247,7 @@ createApp({
} else if (message.type === 'alert') {
// Keyed on a counter, not the timestamp: two alerts inside the same
// second would collide and Vue would reuse the wrong row.
alerts.value = [
{ key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message },
...alerts.value,
].slice(0, 20);
addEvent(message.message, 'alert');
playAlert();
}
};
@ -281,6 +287,82 @@ createApp({
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
}
function encodeCaptureMetadata(metadata) {
const bytes = new TextEncoder().encode(JSON.stringify(metadata));
return btoa([...bytes].map(byte => String.fromCharCode(byte)).join(''));
}
async function captureDiagnostic() {
if (captureBusy.value) return;
if (!navigator.mediaDevices?.getDisplayMedia) {
addEvent('CAPTURE UNAVAILABLE · use HTTPS and a supported browser', 'capture-error');
return;
}
captureBusy.value = true;
let stream = null;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
audio: false,
preferCurrentTab: true,
selfBrowserSurface: 'include',
});
const video = document.createElement('video');
video.muted = true;
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve;
video.onerror = reject;
video.srcObject = stream;
});
await video.play();
await new Promise(resolve => {
if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve());
else requestAnimationFrame(() => resolve());
});
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const image = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
if (!image) throw new Error('Browser did not produce a PNG');
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
const bars = chartApi?.bars || [];
const selected = levels.value.find(level => level.id === selectedLine.value) || null;
const metadata = {
url: location.href,
timeframe: timeframe.value,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
device_pixel_ratio: window.devicePixelRatio,
image_width: canvas.width,
image_height: canvas.height,
first_bar_t: bars[0]?.t ?? null,
last_bar_t: bars[bars.length - 1]?.t ?? null,
bars_held: bars.length,
selected_drawing: selected,
latest_snap: chartApi?.lastSnapDiagnostic ?? null,
commit: version.commit ?? null,
};
const response = await apiFetch('/api/debug/captures', {
method: 'POST',
headers: {
'Content-Type': 'image/png',
'X-Capture-Metadata': encodeCaptureMetadata(metadata),
},
body: image,
});
if (!response.ok) throw new Error(`upload returned HTTP ${response.status}`);
const saved = await response.json();
addEvent(`CAPTURE ${saved.id} · ${timeframe.value}`, 'capture', saved.url);
} catch (error) {
const cancelled = error?.name === 'AbortError' || error?.name === 'NotAllowedError';
addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error');
} finally {
stream?.getTracks().forEach(track => track.stop());
captureBusy.value = false;
}
}
function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool;
selectedLine.value = null;
@ -677,6 +759,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
},
}).mount('#app');

View file

@ -56,6 +56,7 @@ class ConfluenceChart {
this.contextLabelFrame = null;
this.onCommentToggle = null;
this.onCommentMove = null;
this.lastSnapDiagnostic = null;
}
static TICK = 0.25;
@ -899,6 +900,7 @@ class ConfluenceChart {
? 'coordinateToTime returned null'
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
};
this.lastSnapDiagnostic = body;
fetch('/api/debug/snap', {
method: 'POST',
headers: { 'Content-Type': 'application/json',

View file

@ -32,6 +32,9 @@
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
{{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
</button>
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></span>
</div>
</section>
@ -224,9 +227,13 @@
</div>
</details>
<details class="sidebar-section">
<summary>Alert log ({{ alerts.length }})</summary>
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
<summary>Events ({{ events.length }})</summary>
<div v-if="!events.length" class="empty">No events yet.</div>
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
<time>{{ event.at }}</time>
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
<template v-else>{{ event.message }}</template>
</div>
</details>
</aside>
</main>

View file

@ -15,7 +15,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }
@ -53,7 +53,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }

View file

@ -71,6 +71,71 @@ test('an existing browser token is migrated once and removed from local storage'
}
});
test('diagnostic capture uploads a PNG and adds its capability ID to Events',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let uploaded = null;
try {
await page.addInitScript(() => localStorage.setItem('chart-diag', '1'));
await page.route('**/api/debug/captures', async route => {
uploaded = {
contentType: route.request().headers()['content-type'],
metadata: JSON.parse(Buffer.from(
route.request().headers()['x-capture-metadata'], 'base64').toString()),
};
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'c-TESTCAPTURE1',
url: '/api/debug/captures/c-TESTCAPTURE1',
}),
});
});
await openChart(page);
await page.evaluate(() => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
});
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
configurable: true,
set() { setTimeout(() => this.onloadedmetadata?.(), 0); },
});
Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', {
configurable: true, get: () => 800,
});
Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', {
configurable: true, get: () => 600,
});
HTMLMediaElement.prototype.play = async function play() {};
HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); };
HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} });
HTMLCanvasElement.prototype.toBlob = function toBlob(done) {
done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], {
type: 'image/png',
}));
};
});
await page.locator('.diag-capture').click();
await page.waitForFunction(() =>
[...document.querySelectorAll('.alert-entry')]
.some(entry => entry.textContent.includes('c-TESTCAPTURE1')));
assert.equal(uploaded.contentType, 'image/png');
assert.equal(uploaded.metadata.timeframe, '1m');
assert.equal(uploaded.metadata.image_width, 800);
assert.equal(uploaded.metadata.image_height, 600);
const event = page.locator('.alert-entry.capture a');
assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/);
assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/);
assertNoPageErrors(page, assert);
} finally {
await browser.close();
}
});
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
await withChart(async page => {
const state = await chartState(page);

View file

@ -1,3 +1,6 @@
import base64
import json
import pytest
import jwt
from fastapi import FastAPI
@ -5,6 +8,7 @@ from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.api.meta import router as meta_router
from app.api import captures
from app.api.deps import create_session, session_matches, session_secret
from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
@ -170,6 +174,41 @@ def test_writes_are_protected(client):
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
def test_diagnostic_capture_is_authenticated_and_retrievable_by_capability_id(
client, tmp_path, monkeypatch
):
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
probe = client("s3cret")
image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
metadata = base64.b64encode(
json.dumps({"timeframe": "30m", "viewport_width": 1440}).encode()
).decode()
assert probe.post(
"/api/debug/captures",
content=image,
headers={"Content-Type": "image/png", "X-Capture-Metadata": metadata},
).status_code == 401
response = probe.post(
"/api/debug/captures",
content=image,
headers={
"X-Chart-Token": "s3cret",
"Content-Type": "image/png",
"X-Capture-Metadata": metadata,
},
)
assert response.status_code == 201
saved = response.json()
assert saved["id"].startswith("c-")
assert probe.get(saved["url"]).content == image
details = probe.get(saved["metadata_url"]).json()
assert details["id"] == saved["id"]
assert details["timeframe"] == "30m"
assert details["viewport_width"] == 1440
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
def test_meta_endpoints_stay_open(client, path):
"""bin/wait-deploy polls /api/version without carrying the token."""