feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints

Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes:

- `tables.py`: list all tables and inspect table schemas
- `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge
- `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService`
- `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes

Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
This commit is contained in:
2026-06-14 23:00:30 +00:00
parent 96521e8757
commit d31ccba6c1
69 changed files with 3278 additions and 12 deletions
@@ -1400,6 +1400,175 @@ ACTIONS: tuple[Action, ...] = (
params=(path("uid", "Attachment uid to restore."),),
requires_admin=True,
),
Action(
name="db_list_tables",
method="GET",
path="/dbapi/tables",
summary="List database tables exposed by the database API (admin only)",
description=(
"Returns every table reachable through the database API with its row count and "
"whether it uses soft deletes. Use this to discover what data exists before "
"querying or designing a query."
),
params=(),
requires_admin=True,
),
Action(
name="db_table_schema",
method="GET",
path="/dbapi/{table}/schema",
summary="Show a table's columns and types (admin only)",
description="Returns the column names, types, row count, and soft-delete flag for one table.",
params=(path("table", "Table name (from db_list_tables)."),),
requires_admin=True,
),
Action(
name="db_list_rows",
method="GET",
path="/dbapi/{table}",
summary="List rows of a table with keyset pagination (admin only)",
description=(
"Browses rows newest-first. Soft-deleted rows are excluded unless include_deleted is "
"true. For filtered or joined questions prefer db_query or db_design_query."
),
params=(
path("table", "Table name."),
query("limit", "Maximum rows (1-500, default 25)."),
query("search", "Free-text search over common text columns."),
query("before", "Keyset cursor: return rows older than this created_at/id value."),
Param(
name="include_deleted",
location="query",
description="Include soft-deleted rows.",
required=False,
type="boolean",
),
),
requires_admin=True,
),
Action(
name="db_get_row",
method="GET",
path="/dbapi/{table}/{key}/{value}",
summary="Fetch one row by a key column (admin only)",
description="Returns a single row where key column equals value (key is usually 'uid').",
params=(
path("table", "Table name."),
path("key", "Key column to match (usually 'uid')."),
path("value", "Value of the key column."),
),
requires_admin=True,
),
Action(
name="db_query",
method="POST",
path="/dbapi/query",
summary="Run a read-only SQL SELECT and return rows (admin only)",
description=(
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected (use db_insert_row, "
"db_update_row, db_delete_row for changes). The response may include a 'suspicious' "
"list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
"surface that warning to the user before trusting the results."
),
params=(
body("sql", "A single SELECT statement.", required=True),
body("dialect", "Optional source SQL dialect (default sqlite)."),
),
requires_admin=True,
read_only=True,
),
Action(
name="db_design_query",
method="POST",
path="/dbapi/nl",
summary="Design a SQL SELECT from a natural-language question (admin only)",
description=(
"Turns a plain-language question about one table into a validated read-only SELECT. "
"It auto-adds 'deleted_at IS NULL' for soft-delete tables unless apply_soft_delete is "
"false. By default it only returns the SQL; pass execute=true to also run it read-only "
"and return rows. Show the user the SQL and any 'suspicious' notes."
),
params=(
body("question", "The natural-language request.", required=True),
body("table", "Target table the question is about.", required=True),
Param(
name="apply_soft_delete",
location="body",
description="Add deleted_at IS NULL for soft-delete tables (default true).",
required=False,
type="boolean",
),
body("dialect", "Optional target SQL dialect (default sqlite)."),
Param(
name="execute",
location="body",
description="Also run the validated query read-only and return rows.",
required=False,
type="boolean",
),
),
requires_admin=True,
read_only=True,
),
Action(
name="db_insert_row",
method="POST",
path="/dbapi/{table}",
summary="Insert a row into a table (admin only, confirmation required)",
description=(
"Inserts a new row. Pass the column values as a JSON object string in values_json. "
"Soft-delete columns and uid/created_at are filled automatically. Requires confirmation."
),
params=(
path("table", "Table name."),
body("values_json", "JSON object of column:value pairs for the new row.", required=True),
confirm(),
),
requires_admin=True,
),
Action(
name="db_update_row",
method="PATCH",
path="/dbapi/{table}/{key}/{value}",
summary="Update a row in a table (admin only, confirmation required)",
description=(
"Updates the row where key equals value. Pass the changed columns as a JSON object "
"string in values_json. uid and id cannot be changed. Requires confirmation."
),
params=(
path("table", "Table name."),
path("key", "Key column to match (usually 'uid')."),
path("value", "Value of the key column."),
body("values_json", "JSON object of column:value pairs to change.", required=True),
confirm(),
),
requires_admin=True,
),
Action(
name="db_delete_row",
method="DELETE",
path="/dbapi/{table}/{key}/{value}",
summary="Delete a row from a table (admin only, confirmation required)",
description=(
"Soft-deletes the row where key equals value (restorable). Pass hard=true to "
"PERMANENTLY purge it (or for tables without soft delete). Requires confirmation."
),
params=(
path("table", "Table name."),
path("key", "Key column to match (usually 'uid')."),
path("value", "Value of the key column."),
Param(
name="hard",
location="query",
description="Permanently purge instead of soft delete.",
required=False,
type="boolean",
),
confirm(),
),
requires_admin=True,
),
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
@@ -51,6 +51,9 @@ CONFIRM_REQUIRED = {
"admin_reset_guest_ai_quota",
"admin_reset_user_ai_quota",
"notification_reset",
"db_insert_row",
"db_update_row",
"db_delete_row",
}
CONDITIONAL_CONFIRM = {
@@ -212,6 +215,26 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
"explicit confirmation, then call again with confirm=true."
)
if name == "db_insert_row":
table = str(arguments.get("table", "")).strip() or "(unspecified)"
return ToolInputError(
f"This writes a new row directly into the '{table}' table. Show the user the exact "
"table and values, get explicit confirmation, then call again with confirm=true."
)
if name == "db_update_row":
table = str(arguments.get("table", "")).strip() or "(unspecified)"
return ToolInputError(
f"This updates an existing row in the '{table}' table directly. Show the user the "
"exact row and new values, get explicit confirmation, then call again with confirm=true."
)
if name == "db_delete_row":
table = str(arguments.get("table", "")).strip() or "(unspecified)"
hard = str(arguments.get("hard", "")).strip().lower() in ("true", "1", "yes", "on")
kind = "PERMANENTLY purges" if hard else "soft-deletes"
return ToolInputError(
f"This {kind} a row in the '{table}' table. Show the user the exact row, get explicit "
"confirmation, then call again with confirm=true."
)
if name in CONFIRM_REQUIRED:
return ToolInputError(
"This removes the item as a soft delete: it disappears from every surface and is only "
@@ -547,7 +570,7 @@ class Dispatcher:
key = self._file_key(arguments)
if key is not None:
self._read_files.add(key)
if action.method in MUTATING_METHODS:
if action.method in MUTATING_METHODS and not action.is_read_only:
record_mutation(action.name)
store = get_store()
if store is not None: