Skip to content

Commit 68b3f49

Browse files
bk86aclaude
andcommitted
fix(token-db): rewrite TokenDB.execute to speak libsql Hrana v2 protocol (#61)
The v0.17.0 TokenDB assumed a generic POST /query with {sql, params} body — that's not the wire protocol the project's actual managed-database provider uses. Replace with libsql/Hrana v2: - POST {base}/v2/pipeline (with libsql:// rewritten to https://) - Wrap statements in {requests: [{type: "execute", stmt: {sql, args}}]} - Encode params as typed value objects {type: "integer"|"text"|..., value} - Decode rows from arrays of typed value objects back to {col: pyvalue} dicts - Bearer token in Authorization header (new PC2NUTS_TOKEN_DB_AUTH_TOKEN env var) - New --auth-token CLI flag Verified end-to-end against the production database: SELECT round-trips typed values correctly, init_schema is idempotent, add/revoke/list_active/ list_all all work, RETURNING id is honoured. The unit tests now mock the Hrana wire shape and stay fast (172 tests pass in 1.4s). CLI tests' lambda for _make_db extended to accept the new auth_token kwarg. Bandit B107 false positive on `auth_token: str = ""` suppressed with a clearly-explained nosec comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f78e185 commit 68b3f49

6 files changed

Lines changed: 199 additions & 30 deletions

File tree

app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
2020
extra_sources: str = ""
2121
trusted_tokens_raw: str = Field(default="", validation_alias="PC2NUTS_TRUSTED_TOKENS")
2222
token_db_url: str = ""
23+
token_db_auth_token: str = ""
2324
token_refresh_seconds: int = Field(default=60, ge=1)
2425
rate_limit: str = _defaults.get("rate_limit", "60/minute")
2526
rate_limit_headers: bool = _defaults.get("rate_limit_headers", True)

app/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ async def lifespan(app: FastAPI):
108108
from app import auth as auth_mod
109109
from app.token_db import TokenDB
110110

111-
token_db = TokenDB(_config.settings.token_db_url)
111+
token_db = TokenDB(
112+
_config.settings.token_db_url,
113+
auth_token=_config.settings.token_db_auth_token,
114+
)
112115

113116
async def _refresh_loop():
114117
interval = max(1, _config.settings.token_refresh_seconds)

app/token_db.py

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
"""Thin HTTP client for the configured SQLite-compatible managed database.
1+
"""Thin HTTP client for a libsql / SQLite-over-HTTP managed database.
22
33
See docs/superpowers/specs/2026-04-29-db-backed-trusted-tokens-design.md.
44
5-
The wire shape (POST /query with {sql, params}, response {rows, rowsAffected,
6-
lastInsertRowId}) is the working assumption; adjust `execute` if the
7-
configured provider differs.
5+
Wire protocol: Hrana v2 (libsql HTTP). Endpoint is ``POST {base}/v2/pipeline``
6+
where {base} is the database URL with ``libsql://`` rewritten to ``https://``.
7+
The single-statement request is wrapped in ``{requests: [{type: "execute",
8+
stmt: {sql, args}}]}``; rows are returned as arrays of typed value objects
9+
``{type, value}`` with one row per query result, alongside a ``cols`` list
10+
that gives column names. This adapter converts back and forth between
11+
Python-native types and the typed-value envelope so callers get plain dicts.
12+
13+
Authentication: a Bearer token in the Authorization header. Both URL and
14+
token come from environment configuration; no values are committed.
815
"""
916

1017
from __future__ import annotations
@@ -18,31 +25,91 @@ class TokenDBError(Exception):
1825
"""Raised when the token database returns an error or is unreachable."""
1926

2027

28+
def _to_libsql_arg(v: Any) -> dict:
29+
"""Encode a Python value as a libsql typed value object."""
30+
if v is None:
31+
return {"type": "null"}
32+
if isinstance(v, bool):
33+
# libsql has no native bool; SQLite stores 0/1
34+
return {"type": "integer", "value": "1" if v else "0"}
35+
if isinstance(v, int):
36+
return {"type": "integer", "value": str(v)}
37+
if isinstance(v, float):
38+
return {"type": "float", "value": v}
39+
if isinstance(v, (bytes, bytearray)):
40+
import base64
41+
42+
return {"type": "blob", "base64": base64.b64encode(bytes(v)).decode("ascii")}
43+
return {"type": "text", "value": str(v)}
44+
45+
46+
def _from_libsql_value(cell: dict) -> Any:
47+
"""Decode a libsql typed value object back to a Python value."""
48+
t = cell.get("type")
49+
if t == "null":
50+
return None
51+
if t == "integer":
52+
return int(cell["value"])
53+
if t == "float":
54+
return float(cell["value"])
55+
if t == "blob":
56+
import base64
57+
58+
return base64.b64decode(cell["base64"])
59+
# text and unknown types fall through as strings
60+
return cell.get("value")
61+
62+
2163
class TokenDB:
22-
"""Minimal HTTP client over a SQLite-compatible managed database.
64+
"""Minimal HTTP client over a libsql managed database.
2365
2466
All methods are blocking. Callers running inside an asyncio loop should
2567
wrap calls in asyncio.to_thread().
2668
"""
2769

28-
def __init__(self, url: str) -> None:
29-
self.url = url.rstrip("/")
70+
def __init__(self, url: str, auth_token: str = "") -> None: # nosec B107 — empty default means "no auth", not a hardcoded credential
71+
# libsql://host/ → https://host (drop trailing slash, swap scheme)
72+
u = url.strip()
73+
if u.startswith("libsql://"):
74+
u = "https://" + u[len("libsql://") :]
75+
self.url = u.rstrip("/")
76+
self.auth_token = auth_token
3077

3178
def execute(self, sql: str, params: list[Any] | None = None) -> list[dict]:
32-
"""Send a SQL statement. Returns the `rows` list from the response.
79+
"""Send a SQL statement. Returns the result rows as a list of dicts.
3380
34-
Writes (INSERT/UPDATE/DELETE/CREATE) typically return an empty list
35-
unless the SQL uses RETURNING.
81+
Each dict maps column name → Python-native value. Writes (INSERT/UPDATE/
82+
DELETE/CREATE) typically return an empty list unless the SQL uses RETURNING.
83+
84+
Raises TokenDBError on transport failure or libsql-reported error.
3685
"""
37-
payload = {"sql": sql, "params": list(params) if params else []}
86+
stmt: dict = {"sql": sql}
87+
if params:
88+
stmt["args"] = [_to_libsql_arg(p) for p in params]
89+
payload = {"requests": [{"type": "execute", "stmt": stmt}]}
90+
headers = {}
91+
if self.auth_token:
92+
headers["Authorization"] = f"Bearer {self.auth_token}"
93+
3894
try:
3995
with httpx.Client(timeout=10) as client:
40-
resp = client.post(f"{self.url}/query", json=payload, headers={})
96+
resp = client.post(f"{self.url}/v2/pipeline", json=payload, headers=headers)
4197
resp.raise_for_status()
4298
body = resp.json()
4399
except (httpx.HTTPError, ValueError) as exc:
44100
raise TokenDBError(f"DB request failed: {exc}") from exc
45-
return body.get("rows") or []
101+
102+
results = body.get("results") or []
103+
if not results:
104+
return []
105+
first = results[0]
106+
if first.get("type") != "ok":
107+
err = (first.get("error") or {}).get("message", "unknown error")
108+
raise TokenDBError(f"DB statement failed: {err}")
109+
result = (first.get("response") or {}).get("result") or {}
110+
cols = [c.get("name") for c in (result.get("cols") or [])]
111+
rows = result.get("rows") or []
112+
return [{cols[i]: _from_libsql_value(cell) for i, cell in enumerate(row)} for row in rows]
46113

47114
# ── Schema ──────────────────────────────────────────────────────────────
48115

scripts/tokens.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
from app.token_db import TokenDB, TokenDBError
1919

2020

21-
def _make_db(url: str) -> TokenDB:
21+
def _make_db(url: str, auth_token: str = "") -> TokenDB:
2222
"""Indirection seam for tests."""
23-
return TokenDB(url)
23+
return TokenDB(url, auth_token=auth_token)
2424

2525

2626
def _token_id(token: str) -> str:
@@ -35,6 +35,12 @@ def _resolve_db_url(args_url: str | None) -> str | None:
3535
return env or None
3636

3737

38+
def _resolve_auth_token(args_token: str | None) -> str:
39+
if args_token:
40+
return args_token
41+
return os.environ.get("PC2NUTS_TOKEN_DB_AUTH_TOKEN", "").strip()
42+
43+
3844
def _cmd_init(db: TokenDB) -> int:
3945
db.init_schema()
4046
print("Schema initialised (idempotent).")
@@ -95,6 +101,10 @@ def _cmd_revoke(db: TokenDB, token_id_arg: int) -> int:
95101
def main(argv: Sequence[str] | None = None) -> int:
96102
parser = argparse.ArgumentParser(prog="scripts.tokens", description=__doc__)
97103
parser.add_argument("--db-url", help="Override PC2NUTS_TOKEN_DB_URL")
104+
parser.add_argument(
105+
"--auth-token",
106+
help="Override PC2NUTS_TOKEN_DB_AUTH_TOKEN (Bearer token for the DB).",
107+
)
98108
sub = parser.add_subparsers(dest="cmd", required=True)
99109

100110
sub.add_parser("init", help="Create the trusted_tokens table (idempotent)")
@@ -123,7 +133,8 @@ def main(argv: Sequence[str] | None = None) -> int:
123133
)
124134
return 2
125135

126-
db = _make_db(url)
136+
auth_token = _resolve_auth_token(args.auth_token)
137+
db = _make_db(url, auth_token=auth_token)
127138

128139
if args.cmd == "init":
129140
return _cmd_init(db)

tests/test_token_db.py

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ def test_init_strips_trailing_slash(self):
5555
db = TokenDB("https://db.example/v1/")
5656
assert db.url == "https://db.example/v1"
5757

58+
def test_init_rewrites_libsql_scheme(self):
59+
from app.token_db import TokenDB
60+
61+
db = TokenDB("libsql://db.example/")
62+
assert db.url == "https://db.example"
63+
64+
def test_init_stores_auth_token(self):
65+
from app.token_db import TokenDB
66+
67+
db = TokenDB("https://db.example", auth_token="jwt-xyz")
68+
assert db.auth_token == "jwt-xyz"
69+
70+
def test_init_default_auth_token_empty(self):
71+
from app.token_db import TokenDB
72+
73+
db = TokenDB("https://db.example")
74+
assert db.auth_token == ""
75+
5876

5977
class TestTokenDBExecute:
6078
def _mock_response(self, monkeypatch, *, json_body: dict, status_code: int = 200):
@@ -87,33 +105,90 @@ def _post(self, url, json=None, headers=None, timeout=None):
87105
monkeypatch.setattr(httpx.Client, "post", _post)
88106
return captured
89107

90-
def test_execute_sends_post_with_sql_and_params(self, monkeypatch):
108+
def _ok(self, rows: list[list[dict]] | None = None, cols: list[str] | None = None) -> dict:
109+
"""Build a Hrana v2 success-response envelope."""
110+
return {
111+
"results": [
112+
{
113+
"type": "ok",
114+
"response": {
115+
"type": "execute",
116+
"result": {
117+
"cols": [{"name": c} for c in (cols or [])],
118+
"rows": rows or [],
119+
},
120+
},
121+
}
122+
]
123+
}
124+
125+
def test_execute_posts_to_v2_pipeline(self, monkeypatch):
91126
from app.token_db import TokenDB
92127

93-
captured = self._mock_response(monkeypatch, json_body={"rows": [], "rowsAffected": 0})
128+
captured = self._mock_response(monkeypatch, json_body=self._ok())
129+
db = TokenDB("https://db.example/v1")
130+
db.execute("SELECT 1")
131+
assert captured["url"] == "https://db.example/v1/v2/pipeline"
132+
133+
def test_execute_wraps_sql_and_params_in_hrana(self, monkeypatch):
134+
from app.token_db import TokenDB
135+
136+
captured = self._mock_response(monkeypatch, json_body=self._ok())
94137
db = TokenDB("https://db.example/v1")
95138
db.execute("SELECT * FROM t WHERE id = ?", [42])
96139

97-
assert captured["json"] == {"sql": "SELECT * FROM t WHERE id = ?", "params": [42]}
140+
assert captured["json"] == {
141+
"requests": [
142+
{
143+
"type": "execute",
144+
"stmt": {
145+
"sql": "SELECT * FROM t WHERE id = ?",
146+
"args": [{"type": "integer", "value": "42"}],
147+
},
148+
}
149+
]
150+
}
98151

99-
def test_execute_returns_rows(self, monkeypatch):
152+
def test_execute_passes_auth_token_in_header(self, monkeypatch):
100153
from app.token_db import TokenDB
101154

102-
self._mock_response(
103-
monkeypatch,
104-
json_body={"rows": [{"id": 1, "label": "a"}], "rowsAffected": 0},
155+
captured = self._mock_response(monkeypatch, json_body=self._ok())
156+
db = TokenDB("https://db.example/v1", auth_token="jwt-xyz")
157+
db.execute("SELECT 1")
158+
assert captured["headers"].get("Authorization") == "Bearer jwt-xyz"
159+
160+
def test_execute_no_auth_header_when_token_empty(self, monkeypatch):
161+
from app.token_db import TokenDB
162+
163+
captured = self._mock_response(monkeypatch, json_body=self._ok())
164+
db = TokenDB("https://db.example/v1")
165+
db.execute("SELECT 1")
166+
assert "Authorization" not in (captured["headers"] or {})
167+
168+
def test_execute_unwraps_hrana_rows_to_dicts(self, monkeypatch):
169+
from app.token_db import TokenDB
170+
171+
body = self._ok(
172+
cols=["id", "label"],
173+
rows=[
174+
[{"type": "integer", "value": "1"}, {"type": "text", "value": "a"}],
175+
[{"type": "integer", "value": "2"}, {"type": "null"}],
176+
],
105177
)
178+
self._mock_response(monkeypatch, json_body=body)
106179
db = TokenDB("https://db.example/v1")
107180
rows = db.execute("SELECT id, label FROM t")
108-
assert rows == [{"id": 1, "label": "a"}]
181+
assert rows == [{"id": 1, "label": "a"}, {"id": 2, "label": None}]
109182

110-
def test_execute_no_params_sends_empty_list(self, monkeypatch):
183+
def test_execute_no_params_omits_args(self, monkeypatch):
111184
from app.token_db import TokenDB
112185

113-
captured = self._mock_response(monkeypatch, json_body={"rows": [], "rowsAffected": 0})
186+
captured = self._mock_response(monkeypatch, json_body=self._ok())
114187
db = TokenDB("https://db.example/v1")
115188
db.execute("CREATE TABLE x (id INTEGER)")
116-
assert captured["json"]["params"] == []
189+
# When no params, the stmt should not have an "args" key (or have an empty list).
190+
stmt = captured["json"]["requests"][0]["stmt"]
191+
assert stmt.get("args", []) == []
117192

118193
def test_execute_http_error_raises_token_db_error(self, monkeypatch):
119194
from app.token_db import TokenDB, TokenDBError
@@ -123,6 +198,18 @@ def test_execute_http_error_raises_token_db_error(self, monkeypatch):
123198
with pytest.raises(TokenDBError):
124199
db.execute("SELECT 1")
125200

201+
def test_execute_libsql_error_raises_token_db_error(self, monkeypatch):
202+
from app.token_db import TokenDB, TokenDBError
203+
204+
body = {
205+
"results": [{"type": "error", "error": {"message": "UNIQUE constraint failed", "code": "..."}}]
206+
}
207+
self._mock_response(monkeypatch, json_body=body)
208+
db = TokenDB("https://db.example/v1")
209+
with pytest.raises(TokenDBError) as exc_info:
210+
db.execute("INSERT ...")
211+
assert "UNIQUE" in str(exc_info.value)
212+
126213

127214
# ── TokenDB query methods ────────────────────────────────────────────────────
128215

tests/test_tokens_cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def fake_db(monkeypatch):
4646
from scripts import tokens
4747

4848
fake = FakeTokenDB()
49-
monkeypatch.setattr(tokens, "_make_db", lambda url: fake)
49+
monkeypatch.setattr(tokens, "_make_db", lambda url, auth_token="": fake)
5050
monkeypatch.setenv("PC2NUTS_TOKEN_DB_URL", "fake://")
5151
return fake
5252

@@ -210,7 +210,7 @@ def test_db_url_arg_overrides_env(monkeypatch, capsys):
210210
monkeypatch.setattr(
211211
tokens,
212212
"_make_db",
213-
lambda url: (captured_urls.append(url), FakeTokenDB(url))[1],
213+
lambda url, auth_token="": (captured_urls.append(url), FakeTokenDB(url))[1],
214214
)
215215
rc = main(["--db-url", "https://override.example", "init"])
216216
assert rc == 0

0 commit comments

Comments
 (0)