Skip to content

Commit eb08e28

Browse files
feat: enforce debug/host guard and document API versioning
1 parent 4b2a4c6 commit eb08e28

7 files changed

Lines changed: 175 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
## [0.1.0] - 2026-06-02
8+
9+
### Added
10+
11+
- `__version__` in `app.py` for release tracking
12+
- Startup guard refusing `--debug` with a non-loopback `--host`
13+
- [Deprecation policy](docs/deprecation-policy.md) for API and JSON field changes
14+
- API field **stability** tables in `docs/api-reference.md` (stable / experimental / deprecated)
15+
16+
### Changed
17+
18+
- README notes that the server enforces the debug + host safety rule at startup
19+
20+
### Deprecated
21+
22+
- `export_count` on `GET /api/export/state` (documented only; still returned). Use `last_export_session_count`. Removal planned in a follow-up release per [deprecation policy](docs/deprecation-policy.md).

CONTRIBUTING.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ Useful flags:
4040

4141
- `--base-dir PATH` — point at a different `projects/` tree (for tests or fixtures)
4242
- `--exclude-rules PATH` — session exclusion rules file
43-
- `--host 0.0.0.0` — listen on all interfaces (use only on trusted networks)
43+
- `--host 0.0.0.0` — listen on all interfaces (use only on trusted networks; never with `--debug`)
44+
- `--debug` — Flask/Werkzeug debug mode (loopback hosts only; enforced at startup)
45+
46+
## API and release policy
47+
48+
- [CHANGELOG.md](CHANGELOG.md) — user-visible changes per release
49+
- [docs/deprecation-policy.md](docs/deprecation-policy.md) — how deprecated API fields are removed
50+
- [docs/api-reference.md](docs/api-reference.md) — field **stability** (`stable` / `experimental` / `deprecated`)
51+
52+
When changing JSON response shapes, update the API reference stability column and CHANGELOG before removing fields.
4453

4554
## Running tests
4655

@@ -116,6 +125,8 @@ npm run test:coverage # optional
116125
| SPA shell + routing | [`static/index.html`](static/index.html), [`static/js/app.js`](static/js/app.js) |
117126
| Shared frontend utilities | [`static/js/shared/`](static/js/shared/) |
118127
| API documentation | [`docs/api-reference.md`](docs/api-reference.md) |
128+
| Deprecation policy | [`docs/deprecation-policy.md`](docs/deprecation-policy.md) |
129+
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
119130

120131
## Architecture
121132

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ python app.py --base-dir /path/to/claude/projects
6666
> That combination exposes [Werkzeug's interactive debugger](https://werkzeug.palletsprojects.com/en/stable/debug/),
6767
> which allows arbitrary code execution from any browser that can reach the server.
6868
> For typical local browsing, keep the default `--host 127.0.0.1` and omit `--debug`.
69+
> The server **refuses to start** if `--debug` is combined with a non-loopback `--host` (e.g. `0.0.0.0`).
6970
7071
### CLI Export
7172

app.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Flask app that serves the web GUI for browsing sessions."""
22

3+
__version__ = "0.1.0"
4+
35
import argparse
46
import os
57
import sys
@@ -13,6 +15,33 @@
1315
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
1416

1517

18+
def is_loopback_host(host: str) -> bool:
19+
"""True if ``host`` binds only to the local machine (safe with ``--debug``)."""
20+
h = (host or "").strip().lower()
21+
if h in ("127.0.0.1", "localhost", "::1"):
22+
return True
23+
if h.startswith("127.") and h.count(".") == 3:
24+
parts = h.split(".")
25+
try:
26+
return len(parts) == 4 and all(0 <= int(p) <= 255 for p in parts)
27+
except ValueError:
28+
return False
29+
return False
30+
31+
32+
def validate_startup_cli(args: argparse.Namespace) -> None:
33+
"""Refuse ``--debug`` when ``--host`` is reachable off loopback."""
34+
if args.debug and not is_loopback_host(args.host):
35+
print(
36+
"error: --debug is only allowed with a loopback --host "
37+
"(127.0.0.1, localhost, ::1, or 127.x.x.x). "
38+
"Combining --debug with a network-visible --host exposes the "
39+
"Werkzeug debugger and session data to other machines.",
40+
file=sys.stderr,
41+
)
42+
raise SystemExit(1)
43+
44+
1645
def create_app(
1746
base_dir: str | None = None,
1847
exclusion_rules_path: str | None = None,
@@ -60,6 +89,7 @@ def build_cli_parser() -> argparse.ArgumentParser:
6089

6190
if __name__ == "__main__":
6291
args = build_cli_parser().parse_args()
92+
validate_startup_cli(args)
6393

6494
app = create_app(base_dir=args.base_dir, exclusion_rules_path=args.exclude_rules)
6595
print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}")

docs/api-reference.md

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ HTTP API for **claude-code-chat-browser**. All `/api/*` routes return JSON unles
66

77
**Source of truth for error codes:** [`api/error_codes.py`](../api/error_codes.py)
88

9+
**Field stability:** [`deprecation-policy.md`](deprecation-policy.md)
10+
11+
---
12+
13+
## API field stability
14+
15+
Each response field below is labeled:
16+
17+
| Label | Meaning |
18+
|-------|---------|
19+
| **stable** | Will not be renamed or removed without a documented deprecation period |
20+
| **experimental** | May change in any release; do not build long-lived integrations on these fields |
21+
| **deprecated** | Still returned; use the documented replacement; removal announced in [CHANGELOG](../CHANGELOG.md) |
22+
23+
**Migration:** Breaking changes use additive deprecation first (new field → deprecate old → remove after policy period). Versioned routes (e.g. `/api/v2/...`) are reserved for future breaking reshapes; none exist today.
24+
925
---
1026

1127
## Authentication
@@ -100,13 +116,13 @@ None.
100116

101117
`application/json` — array of project objects:
102118

103-
| Field | Type | Description |
104-
|-------|------|-------------|
105-
| `name` | string | Directory name under `~/.claude/projects/` (e.g. `F--boost-capy`) |
106-
| `path` | string | Absolute path to project directory |
107-
| `display_name` | string | Friendly name derived from session `cwd` when available |
108-
| `session_count` | integer | Count of titled sessions (updated in handler) |
109-
| `last_modified` | string (ISO 8601) | Latest message timestamp across titled sessions |
119+
| Field | Type | Stability | Description |
120+
|-------|------|-----------|-------------|
121+
| `name` | string | stable | Directory name under `~/.claude/projects/` (e.g. `F--boost-capy`) |
122+
| `path` | string | stable | Absolute path to project directory |
123+
| `display_name` | string | stable | Friendly name derived from session `cwd` when available |
124+
| `session_count` | integer | stable | Count of titled sessions (updated in handler) |
125+
| `last_modified` | string (ISO 8601) | stable | Latest message timestamp across titled sessions |
110126

111127
```json
112128
[
@@ -148,19 +164,19 @@ Lists sessions in one project with summary fields for the workspace sidebar. Ski
148164

149165
`application/json` — array of session row objects:
150166

151-
| Field | Type | Description |
152-
|-------|------|-------------|
153-
| `id` | string | Session id (filename without `.jsonl`) |
154-
| `path` | string | Absolute path to JSONL file |
155-
| `size_bytes` | integer | File size |
156-
| `modified` | number | File mtime (epoch seconds) |
157-
| `title` | string | Parsed session title |
158-
| `models` | string[] | Models used in session |
159-
| `tokens` | integer | Sum of input + output tokens |
160-
| `tool_calls` | integer | Total tool calls |
161-
| `first_timestamp` | string \| null | First message timestamp |
162-
| `last_timestamp` | string \| null | Last message timestamp |
163-
| `error` | boolean | Optional; `true` if parse failed (card shows error state) |
167+
| Field | Type | Stability | Description |
168+
|-------|------|-----------|-------------|
169+
| `id` | string | stable | Session id (filename without `.jsonl`) |
170+
| `path` | string | stable | Absolute path to JSONL file |
171+
| `size_bytes` | integer | stable | File size |
172+
| `modified` | number | stable | File mtime (epoch seconds) |
173+
| `title` | string | stable | Parsed session title |
174+
| `models` | string[] | stable | Models used in session |
175+
| `tokens` | integer | stable | Sum of input + output tokens |
176+
| `tool_calls` | integer | stable | Total tool calls |
177+
| `first_timestamp` | string \| null | stable | First message timestamp |
178+
| `last_timestamp` | string \| null | stable | Last message timestamp |
179+
| `error` | boolean | stable | Optional; `true` if parse failed (card shows error state) |
164180

165181
#### Errors
166182

@@ -191,14 +207,14 @@ Returns the full parsed session: title, metadata, and messages (including tool c
191207

192208
`application/json` — session object:
193209

194-
| Top-level field | Type | Description |
195-
|-----------------|------|-------------|
196-
| `session_id` | string | Session identifier |
197-
| `title` | string | Inferred title from first human message |
198-
| `messages` | array | Ordered message objects (`role`, `text`/`content`, tool fields, etc.) |
199-
| `metadata` | object | Tokens, models, timestamps, file activity, tool counts, `cwd`, `git_branch`, … |
210+
| Top-level field | Type | Stability | Description |
211+
|-----------------|------|-----------|-------------|
212+
| `session_id` | string | stable | Session identifier |
213+
| `title` | string | stable | Inferred title from first human message |
214+
| `messages` | array | stable | Ordered message objects (`role`, `text`/`content`, tool fields, etc.) |
215+
| `metadata` | object | stable | Tokens, models, timestamps, file activity, tool counts, `cwd`, `git_branch`, … |
200216

201-
See [`utils/jsonl_parser.py`](../utils/jsonl_parser.py) `parse_session()` for the full metadata shape.
217+
Nested keys inside `messages[]` and `metadata` follow the parser output; new parser fields may appear as **experimental** until listed here. See [`utils/jsonl_parser.py`](../utils/jsonl_parser.py) `parse_session()` for the full metadata shape.
202218

203219
#### Errors
204220

@@ -306,11 +322,11 @@ Read-only snapshot of bulk-export state persisted under `~/.claude-code-chat-bro
306322

307323
#### Response — `200 OK`
308324

309-
| Field | Type | Description |
310-
|-------|------|-------------|
311-
| `last_export_time` | string \| null | ISO timestamp of last completed bulk export |
312-
| `last_export_session_count` | integer | Sessions in last bulk export run |
313-
| `export_count` | integer | **Legacy alias** — same value as `last_export_session_count`; prefer `last_export_session_count` in new integrations (kept for SPA backwards compatibility) |
325+
| Field | Type | Stability | Description |
326+
|-------|------|-----------|-------------|
327+
| `last_export_time` | string \| null | stable | ISO timestamp of last completed bulk export |
328+
| `last_export_session_count` | integer | stable | Sessions in last bulk export run |
329+
| `export_count` | integer | deprecated | Legacy alias of `last_export_session_count`; prefer `last_export_session_count` in new code (still returned for SPA compatibility; removal per [deprecation-policy.md](deprecation-policy.md)) |
314330

315331
```json
316332
{

docs/deprecation-policy.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Deprecation policy
2+
3+
This document defines how **claude-code-chat-browser** evolves its HTTP JSON API and CLI without breaking integrators and the bundled SPA unexpectedly.
4+
5+
## Principles
6+
7+
1. **Documented fields are a contract.** See [API reference](api-reference.md) — each field is marked `stable`, `experimental`, or `deprecated`.
8+
2. **Additive first.** Prefer adding a new field over renaming an existing one.
9+
3. **Deprecate before removing.** A deprecated field remains in responses for at least **one release** after the deprecation is announced in [CHANGELOG](../CHANGELOG.md) and the API reference.
10+
4. **SPA and scripts.** Update `static/js/*.js` and any internal callers before removing a field.
11+
12+
## How we announce deprecation
13+
14+
| Channel | What to update |
15+
|---------|----------------|
16+
| CHANGELOG | `### Deprecated` under the release that announces the change |
17+
| API reference | Set field stability to `deprecated` with a short note and replacement |
18+
| Response (optional) | Future: `Deprecation` header or JSON `_deprecated` map — not required today |
19+
20+
## Removal criteria
21+
22+
A deprecated field may be removed when:
23+
24+
- At least one release has shipped with the field still present but documented as deprecated, and
25+
- The bundled SPA no longer reads the field, and
26+
- Tests and CHANGELOG document the removal.
27+
28+
## Example (in progress)
29+
30+
| Field | Endpoint | Status | Replacement |
31+
|-------|----------|--------|-------------|
32+
| `export_count` | `GET /api/export/state` | deprecated | `last_export_session_count` |
33+
34+
## Versioning
35+
36+
Release versions follow `MAJOR.MINOR.PATCH` in `app.__version__` and [CHANGELOG](../CHANGELOG.md). This project is pre-1.0; minor releases may add features; patch releases are fixes and documentation.

tests/test_cli_args.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
2222
sys.path.insert(0, REPO_ROOT)
2323

24-
from app import build_cli_parser
24+
from app import build_cli_parser, is_loopback_host, validate_startup_cli
2525
from scripts.export import build_parser
2626

2727

@@ -321,6 +321,31 @@ def test_debug_explicit_true(self):
321321
args = parser.parse_args(["--debug"])
322322
assert args.debug is True
323323

324+
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1", "127.0.0.2"])
325+
def test_is_loopback_host_accepts_loopback(self, host: str) -> None:
326+
assert is_loopback_host(host)
327+
328+
@pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.1", "", "example.com"])
329+
def test_is_loopback_host_rejects_non_loopback(self, host: str) -> None:
330+
assert not is_loopback_host(host)
331+
332+
def test_validate_startup_cli_allows_loopback_debug(self) -> None:
333+
parser = build_cli_parser()
334+
args = parser.parse_args(["--host", "127.0.0.1", "--debug"])
335+
validate_startup_cli(args)
336+
337+
def test_validate_startup_cli_rejects_non_loopback_debug(self) -> None:
338+
parser = build_cli_parser()
339+
args = parser.parse_args(["--host", "0.0.0.0", "--debug"])
340+
with pytest.raises(SystemExit) as exc_info:
341+
validate_startup_cli(args)
342+
assert exc_info.value.code == 1
343+
344+
def test_validate_startup_cli_allows_non_loopback_without_debug(self) -> None:
345+
parser = build_cli_parser()
346+
args = parser.parse_args(["--host", "0.0.0.0"])
347+
validate_startup_cli(args)
348+
324349
def test_app_py_debug_not_hardcoded_true(self):
325350
"""app.run() must wire debug from args, not a literal True."""
326351
app_path = os.path.join(REPO_ROOT, "app.py")

0 commit comments

Comments
 (0)