Allows an authenticated user to inspect the per-project PostgreSQL database from within the Workspace “Under-the-hood” mode: list all tables, view the schema of a selected table (columns, types, nullable, default), and browse paginated row data.
- Each project has its own PostgreSQL database whose name equals the project slug (e.g. slug
wildwood-bakery→ databasewildwood-bakery). - The
projectstable has a dedicatedslug VARCHAR(100) UNIQUE NOT NULLcolumn (same migration as UC-07001; all three DB Explorer endpoints look up the project by theslugcolumn directly — see UC-07001 for slug column details and migration notes). - Database credentials (host, port, username, password, database name) are stored in
CLAUDE.mdinside the project directory. BE parses them from that file using a structured regex at request time (no persistent storage of credentials). - If
CLAUDE.mddoes not exist (project never had a conversation) or does not contain a## Databasesection, BE returns a specific error response so FE can display an appropriate empty state in the Viewer. - All DB queries are executed with read-only intent (SELECT only). No schema mutations or DML operations are exposed.
- Row data is paginated using
?page=0&size=50(offset-based). Maximum page size is 200. - Column ordering in the schema endpoint follows the ordinal position in the PostgreSQL catalog (
pg_attribute.attnum). - Tables are listed in alphabetical order (
ORDER BY table_name ASC). - SQL NULL values are serialized in the API response as JSON
null. FE renders them in italic to distinguish null from an empty string. - BE returns JSON
nullliteral for SQL NULL values.
Credentials Parsing from CLAUDE.md
GetProjectDbCredentialsUseCase reads <talkide.output-dir>/<slug>/CLAUDE.md and extracts the
## Database section. The generated format (from ClaudeCliService.generateClaudeMd) is:
## Database
- PostgreSQL 17, database: `<slug>`
- Username: root, Password: 30+Dodge+78
- Port: 5432
Parsing rules (applied line by line within the ## Database section):
| Field | Regex pattern | Example match |
|---|---|---|
database | database:\s*`([^`]+)` | wildwood-bakery |
username | Username:\s*(\S+) | root |
password | Password:\s*(.+) | 30+Dodge+78 |
port | Port:\s*(\d+) | 5432 |
host | Host:\s*(\S+) (optional) | localhost (default if absent) |
Defaults: host=localhost (BE server localhost where PG runs).
The parsed credentials are used to open a short-lived JDBC connection (not pooled) for the duration of the request and closed immediately after. Credentials are never logged, cached to DB, or included in API responses.
Security Debt
MVP uses root PostgreSQL credentials parsed from the project’s CLAUDE.md for all Database Explorer queries. This is acceptable for MVP because:
- (a) The Explorer endpoints are GET-only and execute SELECT-only queries.
- (b) The project DBs contain non-sensitive synthetic data.
Post-MVP requirement: migrate to a dedicated talkide_readonly PostgreSQL role with explicit GRANT SELECT on all tables, provisioned during project creation. Using root credentials for read-only queries is a known security debt.
Decision recorded by Míra (PM) on 2026-05-02.
API Endpoints
GET /api/v1/projects/{slug}/db/tables — List Tables
sequenceDiagram
actor User
User->>+FE: selects "Databáze" tab in Explorer
FE->>+BE: GET /api/v1/projects/{slug}/db/tables <br> Authorization: Bearer {accessToken}
BE->>BE: validate access token
alt token missing or invalid
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>DB: load project by slug column (TalkIDE main DB)
alt project not found
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>BE: check project.tenantId == user's tenantId
alt project belongs to different tenant
BE-->>FE: 403 Forbidden <br> ErrorResponse
end
BE->>BE: parse CLAUDE.md for DB credentials
alt CLAUDE.md missing or Database section absent
BE-->>FE: 503 Service Unavailable <br> ErrorResponse (DB_NOT_READY)
end
BE->>ProjectDB: open JDBC connection, query pg_tables WHERE schemaname='public' ORDER BY tablename ASC
alt connection failed (DB not provisioned yet)
BE-->>FE: 503 Service Unavailable <br> ErrorResponse (DB_NOT_READY)
end
BE->>-FE: 200 OK <br> TableListResponse
FE->>-User: render flat list of table names in Explorer
GET /api/v1/projects/{slug}/db/tables
200 OK TableListResponse:
{
"database": "wildwood-bakery",
"tables": [
{ "name": "orders" },
{ "name": "products" },
{ "name": "users" }
]
}
200 OK (database exists but has no tables):
{
"database": "wildwood-bakery",
"tables": []
}
401 Unauthorized (missing or invalid access token) ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
403 Forbidden (project belongs to a different tenant) ErrorResponse:
{
"status": 403,
"code": "FORBIDDEN",
"message": "You do not have access to this project"
}
404 Not Found (project not found) ErrorResponse:
{
"status": 404,
"code": "NOT_FOUND_PROJECT",
"message": "Project not found"
}
503 Service Unavailable (CLAUDE.md absent, Database section missing, or DB connection failed) ErrorResponse:
{
"status": 503,
"code": "DB_NOT_READY",
"message": "Project database is not available yet"
}
TableInfo DTO
| Field | Type | Nullable | Description |
|---|---|---|---|
name | string | NOT NULL | Table name in the public schema |
GET /api/v1/projects/{slug}/db/tables/{table}/schema — Table Schema
sequenceDiagram
actor User
User->>+FE: clicks table name in Explorer, selects "Schéma" sub-tab in Viewer
FE->>+BE: GET /api/v1/projects/{slug}/db/tables/{table}/schema <br> Authorization: Bearer {accessToken}
BE->>BE: validate access token + tenant ownership (same as above)
BE->>BE: parse CLAUDE.md for DB credentials
BE->>ProjectDB: query information_schema.columns WHERE table_schema='public' AND table_name=? ORDER BY ordinal_position
alt table not found in catalog
BE-->>FE: 404 Not Found <br> ErrorResponse (NOT_FOUND_TABLE)
end
BE->>-FE: 200 OK <br> TableSchemaResponse
FE->>-User: render schema (column list) in Viewer "Schéma" sub-tab
GET /api/v1/projects/{slug}/db/tables/{table}/schema
Path parameters:
| Parameter | Type | Description |
|---|---|---|
slug | string | Project slug |
table | string | Table name (public schema) |
200 OK TableSchemaResponse:
{
"table": "orders",
"columns": [
{
"name": "id",
"type": "bigint",
"nullable": false,
"defaultValue": "nextval('orders_id_seq'::regclass)"
},
{
"name": "user_id",
"type": "bigint",
"nullable": false,
"defaultValue": null
},
{
"name": "status",
"type": "character varying",
"nullable": false,
"defaultValue": "'PENDING'::character varying"
},
{
"name": "total_amount",
"type": "numeric",
"nullable": true,
"defaultValue": null
},
{
"name": "created_at",
"type": "timestamp with time zone",
"nullable": false,
"defaultValue": "now()"
}
]
}
200 OK (table exists but has no columns — unlikely in practice):
{
"table": "orders",
"columns": []
}
404 Not Found (table not found in public schema) ErrorResponse:
{
"status": 404,
"code": "NOT_FOUND_TABLE",
"message": "Table not found"
}
(401, 403, 404 project, 503 — same as GET /tables above)
ColumnInfo DTO
| Field | Type | Nullable | Description |
|---|---|---|---|
name | string | NOT NULL | Column name |
type | string | NOT NULL | PostgreSQL data type as returned by information_schema.columns.data_type (e.g. bigint, character varying, timestamp with time zone) |
nullable | boolean | NOT NULL | true if IS_NULLABLE = 'YES' |
defaultValue | string | NULL | Column default expression, or null if no default is defined |
GET /api/v1/projects/{slug}/db/tables/{table}/data — Table Data (Paged)
sequenceDiagram
actor User
User->>+FE: clicks table name in Explorer, selects "Data" sub-tab in Viewer
FE->>+BE: GET /api/v1/projects/{slug}/db/tables/{table}/data?page=0&size=50 <br> Authorization: Bearer {accessToken}
BE->>BE: validate access token + tenant ownership
BE->>BE: parse CLAUDE.md for DB credentials
BE->>ProjectDB: SELECT * FROM "{table}" ORDER BY 1 LIMIT ? OFFSET ?
alt table not found (relation does not exist)
BE-->>FE: 404 Not Found <br> ErrorResponse (NOT_FOUND_TABLE)
end
BE->>ProjectDB: SELECT COUNT(*) FROM "{table}"
BE->>-FE: 200 OK <br> TableDataResponse
FE->>-User: render paginated data table in Viewer "Data" sub-tab
GET /api/v1/projects/{slug}/db/tables/{table}/data?page=0&size=50
Query parameters:
| Parameter | Required | Default | Min | Max | Description |
|---|---|---|---|---|---|
page | no | 0 | 0 | — | Zero-based page index |
size | no | 50 | 1 | 200 | Rows per page |
200 OK TableDataResponse:
{
"table": "orders",
"columns": ["id", "user_id", "status", "total_amount", "created_at"],
"rows": [
[1, 42, "COMPLETED", "199.90", "2026-04-30T10:00:00Z"],
[2, 43, "PENDING", null, "2026-04-30T11:00:00Z"]
],
"page": 0,
"size": 50,
"totalRows": 2
}
Row values are serialized as JSON primitives where possible (number, boolean, null, string). All PostgreSQL-specific types that have no direct JSON equivalent (e.g. uuid, jsonb, array, timestamp) are serialized as strings using their PostgreSQL text representation. SQL NULL values are serialized as JSON null; FE renders them in italic.
404 Not Found (table not found) ErrorResponse:
{
"status": 404,
"code": "NOT_FOUND_TABLE",
"message": "Table not found"
}
(401, 403, 404 project, 503 — same as GET /tables above)
TableDataResponse DTO
| Field | Type | Nullable | Description |
|---|---|---|---|
table | string | NOT NULL | Table name |
columns | string[] | NOT NULL | Ordered list of column names matching the row arrays |
rows | any[][] | NOT NULL | Array of rows; each row is an array of values in the same order as columns. SQL NULL is serialized as JSON null. |
page | integer | NOT NULL | Current zero-based page index |
size | integer | NOT NULL | Requested page size |
totalRows | integer | NOT NULL | Total number of rows in the table (from COUNT(*)) |
Frontend
UX Guidelines
[Placeholder — Tereza doplní design spec pro Database Explorer panel (flat table list s Table ikonou z lucide), Viewer sub-přepínač “Schéma” / “Data”, tabulkový layout dat (monospace, malé fonty), prázdný stav, skeleton loader, pager.]
- SQL NULL values in the Data tab are rendered in italic (e.g.
nullin italic) to visually distinguish them from the string"null"or an empty string. - Tables in the Explorer panel are listed in alphabetical order.
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
slug (path) | not_blank | — | ^[a-z0-9-]+$ | From project context |
table (path) | not_blank | — | — | From Explorer selection |
page (query) | min=0 | — | — | Optional, default 0 |
size (query) | min=1, max=200 | — | — | Optional, default 50 |
Backend
Security Note on Table Name in SQL
The table path variable is used directly in a SELECT * FROM "<table>" query. Because parameterized queries cannot parametrize table names, BE must validate the table name against the list returned by pg_tables before constructing the query. The name is quoted with double-quotes to prevent SQL injection from special characters. If the provided table name does not exist in public schema → 404 NOT_FOUND_TABLE.
Validations
| Field | Constraints | Note |
|---|---|---|
slug | not_blank; project exists in TalkIDE DB (by slug column) and belongs to user’s tenant | Direct DB lookup by slug column |
table | not_blank; must exist in public schema of project DB | Validated against pg_tables before query execution |
page | min=0 | Default 0 |
size | min=1, max=200 | Default 50 |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Authenticated user, project DB exists with 3 tables | GET /api/v1/projects/{slug}/db/tables is called | 200 OK with list of 3 table names ordered alphabetically (ORDER BY table_name ASC) |
| Authenticated user, project DB exists but has no tables | GET /api/v1/projects/{slug}/db/tables is called | 200 OK with empty tables array |
| Authenticated user, CLAUDE.md exists, DB does not exist yet (never provisioned) | GET /api/v1/projects/{slug}/db/tables is called | 503 DB_NOT_READY |
| Authenticated user, CLAUDE.md does not exist (project dir was never created) | GET /api/v1/projects/{slug}/db/tables is called | 503 DB_NOT_READY |
Authenticated user, table orders has 5 columns | GET /api/v1/projects/{slug}/db/tables/orders/schema is called | 200 OK with 5 columns in ordinal_position order; nullable and defaultValue populated correctly |
Authenticated user, table name nonexistent does not exist in DB | GET /api/v1/projects/{slug}/db/tables/nonexistent/schema is called | 404 NOT_FOUND_TABLE |
Authenticated user, table orders has 120 rows | GET /api/v1/projects/{slug}/db/tables/orders/data?page=0&size=50 is called | 200 OK; 50 rows returned; totalRows=120 |
Authenticated user, table orders has 120 rows, page=2, size=50 | GET /api/v1/projects/{slug}/db/tables/orders/data?page=2&size=50 is called | 200 OK; 20 rows returned (last page); totalRows=120 |
| Authenticated user, table is empty | GET /api/v1/projects/{slug}/db/tables/orders/data is called | 200 OK; rows=[], totalRows=0 |
Authenticated user, table orders has a row with NULL value in total_amount | GET /api/v1/projects/{slug}/db/tables/orders/data is called | 200 OK; affected row contains JSON null literal for that column |
| size=300 (exceeds max) | GET /api/v1/projects/{slug}/db/tables/orders/data?size=300 is called | 400 Bad Request with validation error |
SQL injection attempt via table name (path variable orders; DROP TABLE users) | GET /api/v1/projects/{slug}/db/tables/orders%3B%20DROP%20TABLE%20users/schema is called | 404 NOT_FOUND_TABLE (table not in pg_tables whitelist; no SQL executed) |
| No Authorization header | GET /api/v1/projects/{slug}/db/tables is called | 401 AUTHENTICATION_FAILED error response |
| Authenticated user, project belongs to different tenant | GET /api/v1/projects/{slug}/db/tables is called | 403 FORBIDDEN error response |
| Project slug not found in DB | GET /api/v1/projects/{slug}/db/tables is called | 404 NOT_FOUND_PROJECT error response |
Post-MVP / Future Work
- Read-only DB user — MVP uses
rootPostgreSQL credentials fromCLAUDE.md. Post-MVP: create a dedicatedtalkide_readonlyPostgreSQL role withGRANT SELECTon all tables, provisioned during project creation. See Security Debt section above. - JDBC connection pooling — MVP uses a short-lived, non-pooled connection per request. For heavily used Explorer (e.g. auto-refresh), consider a per-project
HikariCPmini-pool with cleanup on project deletion or archival. - Row ordering for stable pagination — MVP uses
ORDER BY 1(first column). Post-MVP: detect the primary key frompg_constraintand order by it if available, for consistent pagination across pages. totalRowsCOUNT(*) on large tables — MVP usesCOUNT(*). For tables with millions of rows this adds latency. Post-MVP: usepg_class.reltuplesas a fast estimate for large tables.- Credential caching — MVP parses
CLAUDE.mdon every request. Post-MVP: cache parsed credentials in aConcurrentHashMap<slug, ParsedDbCredentials>with TTL, invalidated when the project directory is recreated. - Testcontainers-based integration tests — Add Testcontainers-based integration tests for DB Explorer endpoints covering: alphabetical ordering, empty tables, pagination, NULL values, SQL injection prevention via table name validation, nonexistent table → 404.
Thanks for the feedback.