Allows an authenticated user to browse the generated project directory and read the content of individual files from within the Workspace “Under-the-hood” mode. Access is restricted to the requesting user’s tenant.
- The
projectstable has a dedicatedslug VARCHAR(100) UNIQUE NOT NULLcolumn. The slug is generated at project creation time using a shared utility/domain service extracted fromSendMessageUseCase.extractProjectSlug()logic: ifproject.urlis not null and contains., take the substring before the first dot (e.g.wildwood-bakery.talkide.app→wildwood-bakery); ifproject.urlis null, useprojectId.toString(). The Liquibase migration for theprojectstable is modified in-place (pre-production phase — DB is dropped and recreated, no backfill script needed). - The project directory lives on the BE server at
<talkide.output-dir>/<slug>/. - The Files Explorer uses lazy per-folder loading. Every expand of a directory node triggers a single-level fetch of that directory’s children. The full recursive tree is never returned by a single call. The initial load of the Explorer panel fetches only the root level.
- The set of entries visible at each level is controlled by a hierarchical whitelist configured in
application.yaml(see Whitelist below). The whitelist constrains the top three levels (/,/backend,/frontend); below those levels the tree is unrestricted apart from defense-in-depth blacklist (see Blacklist below). CLAUDE.mdand.project-config.ymllocated directly in the project root are excluded from the response (they contain TalkIDE-internal context / DB credentials). Files of these names in subdirectories are returned normally.- BE validates every requested
pathagainst path traversal attacks: the resolved canonical path must start with the canonical project root. Any attempt to escape the root returns404 Not Found(the response is deliberately indistinguishable from “path does not exist” so as not to leak the rule). - Symlinks are followed. After resolving to their real target, the canonical prefix check is applied again. Any symlink whose real target is outside the project root is excluded.
- Binary files are detected by MIME type sniffing (Java
Files.probeContentType) and by checking for null bytes in the first 8 KB. Binary files are flagged on the file content endpoint and their content is not returned as raw bytes. - The maximum displayable file size is 500 KB. Files larger than 500 KB are not read; BE returns a
FileContentResponsewithtooLarge: true, the actualsizein bytes, and nocontentfield. FE displays the error state “Soubor je příliš velký pro zobrazení (500 KB limit)”. - The Viewer renders file content as read-only plain text / monospace. No syntax highlighting in MVP.
- The Explorer panel has a Refresh button in its header. Clicking it invalidates the FE per-path cache and reloads the root level. Subdirectories are then re-fetched on first expand again. No WebSocket or SSE is used in MVP.
Whitelist
TalkIDE projects have a standardized layout (backend/, docs/, frontend/). The Project Explorer shows users only the “user-facing” content, never build / deploy artefacts or BE-managed sidecar files. The whitelist is hierarchical and applies only to the top three levels:
| Path | Allowed directories | Allowed files |
|---|---|---|
/ (root) | backend, docs, frontend | (none) |
/backend | src | (none) |
/frontend | src | index.html, package.json, package-lock.json, postcss.config.js, tailwind.config.js, tsconfig.json, tsconfig.node.json, vite.config.ts |
/docs | (all, recursive) | (all, recursive) |
anywhere below */src | (all) | (all) |
Key rule: the whitelist map is keyed by parent path. A request for children of a path that has no whitelist entry returns the directory’s children unrestricted (modulo the blacklist below). This is what makes /docs, /backend/src/... and /frontend/src/... “open” subtrees while the top three levels stay tightly curated.
The whitelist is configurable via talkide.explorer.whitelist in application.yaml. To change which top-level entries appear, edit the YAML — no code change required.
Blacklist (SKIPPED_DIRS)
Defense-in-depth: a hardcoded set of directory names is always skipped, no matter where they appear in the tree. This prevents accidental leakage of build artefacts that might end up outside their standard location.
node_modules, dist, build, target, .gradle, .idea, .vscode, .git,
__pycache__, .pytest_cache, .mypy_cache, .next, .nuxt, .venv, venv
The blacklist applies even deep inside */src, where the whitelist would otherwise allow everything. It is enforced inside GetProjectFilesUseCase and is independent of the YAML whitelist.
Path Traversal Security
BE security logic applied by GetProjectFilesUseCase and GetProjectFileContentUseCase:
- Resolve
talkide.output-dir+slug→projectRoot(canonical absolute path). - Resolve
projectRoot+ client-suppliedpath→requestedPath(canonical absolute path, following symlinks). - Assert
requestedPath.startsWith(projectRoot). If false →404 Not Found. - Assert
requestedPathexists. If false →404 Not Found. - Apply whitelist check on the first segment of
path(when it is one of the constrained levels). If the first segment is not in the allowed set for its parent, return404 Not Found(e.g.?path=backend/buildreturns 404 becausebuildis not whitelisted under/backend). - For the content endpoint only: check
requestedPathis not a hidden root-level file (CLAUDE.md,.project-config.yml). If it is →404 Not Found.
Step 2 uses Path.resolve(clientPath).normalize().toRealPath() — symlinks are resolved to their real target, and the prefix check is re-applied after resolution to prevent symlink escape.
Slug Column — Database Migration
The projects table Liquibase migration (pre-production, modified in-place) includes:
slug VARCHAR(100) NOT NULL UNIQUE
Slug validation rules (applied at project creation):
- Lowercase letters, digits, and hyphens only
- Kebab-case format
- Maximum 100 characters
- Regex:
^[a-z0-9]+(-[a-z0-9]+)*$ - Unique constraint enforced at the DB level
API Endpoints
GET /api/v1/projects/{slug}/files — List Directory Children (lazy)
Returns the single-level children of one directory. The root level is fetched without path (or with path=); deeper levels are fetched by passing the relative path of the directory whose children should be listed.
sequenceDiagram
actor User
User->>+FE: opens Explorer, selects "Soubory" tab
FE->>+BE: GET /api/v1/projects/{slug}/files <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 id
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: resolve project root, list root entries, apply whitelist (/) + blacklist, hide HIDDEN_ROOT_FILES
BE->>-FE: 200 OK <br> ChildrenResponse (root entries)
FE->>FE: cache root entries per path in Pinia store
FE->>-User: render root level in Explorer panel
Note over User,FE: User clicks an expand toggle on directory node
User->>+FE: expands directory, e.g. "frontend/src"
FE->>FE: check per-path cache
alt children already cached
FE-->>User: render cached children
end
FE->>+BE: GET /api/v1/projects/{slug}/files?path=frontend/src <br> Authorization: Bearer {accessToken}
BE->>BE: validate access token, tenant ownership
BE->>BE: path traversal check (canonicalize + prefix assert, symlink-safe)
alt path escapes project root or first segment violates whitelist
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>BE: list directory entries, apply whitelist for parent path (if any), apply SKIPPED_DIRS blacklist
alt directory does not exist
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>-FE: 200 OK <br> ChildrenResponse
FE->>FE: cache children under "frontend/src"
FE->>-User: render expanded subtree
GET /api/v1/projects/{slug}/files
Path parameters:
| Parameter | Type | Description |
|---|---|---|
slug | string | Project slug (kebab-case, derived from project.url or project.id); see Slug Column — Database Migration |
Query parameters:
| Parameter | Required | Description |
|---|---|---|
path | no | URL-encoded relative path of the directory whose children should be listed. Empty (?path=) or absent → root level. Must use / as separator and not start with /. |
200 OK ChildrenResponse (root level — no ?path):
{
"path": "",
"entries": [
{ "name": "backend", "type": "DIRECTORY" },
{ "name": "docs", "type": "DIRECTORY" },
{ "name": "frontend", "type": "DIRECTORY" }
]
}
200 OK ChildrenResponse (?path=frontend):
{
"path": "frontend",
"entries": [
{ "name": "src", "type": "DIRECTORY" },
{ "name": "index.html", "type": "FILE" },
{ "name": "package.json", "type": "FILE" },
{ "name": "package-lock.json", "type": "FILE" },
{ "name": "postcss.config.js", "type": "FILE" },
{ "name": "tailwind.config.js", "type": "FILE" },
{ "name": "tsconfig.json", "type": "FILE" },
{ "name": "tsconfig.node.json", "type": "FILE" },
{ "name": "vite.config.ts", "type": "FILE" }
]
}
200 OK ChildrenResponse (?path=frontend/src — open subtree below */src):
{
"path": "frontend/src",
"entries": [
{ "name": "components", "type": "DIRECTORY" },
{ "name": "stores", "type": "DIRECTORY" },
{ "name": "App.vue", "type": "FILE" },
{ "name": "main.ts", "type": "FILE" }
]
}
200 OK ChildrenResponse (project directory does not exist yet — no conversation ever started, root request):
{
"path": "",
"entries": []
}
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, path does not exist, path traversal attempt, or first segment not in whitelist) ErrorResponse:
{
"status": 404,
"code": "NOT_FOUND",
"message": "Path not found"
}
ChildrenResponse DTO
| Field | Type | Nullable | Description |
|---|---|---|---|
path | string | NOT NULL | Relative path of the directory whose children are returned. Empty string for the root level. Echoes the request path parameter. |
entries | Entry[] | NOT NULL | Ordered list of immediate children (directories first, then files; alphabetical within each group). Empty array if directory exists but is empty. |
Entry DTO
| Field | Type | Nullable | Description |
|---|---|---|---|
name | string | NOT NULL | File or directory name (without parent path) |
type | string | NOT NULL | DIRECTORY | FILE |
Note: the lazy endpoint intentionally returns no binary flag — that information is only relevant when the user actually opens a file, and is provided by the content endpoint response.
GET /api/v1/projects/{slug}/files/content — Read File Content
sequenceDiagram
actor User
User->>+FE: clicks a file node in Explorer
FE->>+BE: GET /api/v1/projects/{slug}/files/content?path=frontend/src/main.ts <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
alt project not found
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>BE: check tenant ownership
alt project belongs to different tenant
BE-->>FE: 403 Forbidden <br> ErrorResponse
end
BE->>BE: path traversal check (canonicalize + prefix assert, symlink-safe)
alt path escapes project root
BE-->>FE: 400 Bad Request <br> ErrorResponse (INVALID_PATH)
end
BE->>BE: check file exists
alt file not found (or is a HIDDEN_ROOT_FILES entry)
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>BE: check binary flag
alt file is binary
BE-->>FE: 400 Bad Request <br> ErrorResponse (BINARY_FILE)
end
BE->>BE: check file size (max 500 KB)
alt file exceeds 500 KB
BE-->>FE: 200 OK <br> FileContentResponse (tooLarge: true, no content)
end
BE->>BE: read file content (UTF-8)
BE->>-FE: 200 OK <br> FileContentResponse
FE->>-User: render content in Viewer (read-only, monospace)
GET /api/v1/projects/{slug}/files/content?path=frontend/src/main.ts
Query parameters:
| Parameter | Required | Description |
|---|---|---|
path | yes | Relative path to the file from the project root (e.g. frontend/src/main.ts, docs/README.md). Must not be empty. |
200 OK FileContentResponse (file within size limit):
{
"path": "frontend/src/main.ts",
"name": "main.ts",
"sizeBytes": 1234,
"tooLarge": false,
"content": "import { createApp } from 'vue'\nimport App from './App.vue'\n\ncreateApp(App).mount('#app')\n"
}
200 OK FileContentResponse (file exceeds 500 KB limit):
{
"path": "frontend/src/generated-schema.ts",
"name": "generated-schema.ts",
"sizeBytes": 892340,
"tooLarge": true
}
400 Bad Request (path traversal attempt) ErrorResponse:
{
"status": 400,
"code": "INVALID_PATH",
"message": "Requested path is outside the project directory"
}
400 Bad Request (binary file) ErrorResponse:
{
"status": 400,
"code": "BINARY_FILE",
"message": "File is binary and cannot be displayed as text"
}
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 (file not found, or is a HIDDEN_ROOT_FILES entry) ErrorResponse:
{
"status": 404,
"code": "NOT_FOUND_FILE",
"message": "File not found"
}
FileContentResponse DTO
| Field | Type | Nullable | Description |
|---|---|---|---|
path | string | NOT NULL | Relative path from project root as supplied in the request |
name | string | NOT NULL | File name (last segment of path) |
sizeBytes | integer | NOT NULL | File size in bytes |
tooLarge | boolean | NOT NULL | true if the file exceeds the 500 KB limit; in that case content is absent |
content | string | NULL | Full UTF-8 text content of the file. Absent when tooLarge is true. |
Frontend
UX Guidelines
[Placeholder — Tereza doplní design spec pro Explorer panel, Viewer panel, file icons (Folder, FileText, FileCode z lucide), tree indentation, selected state, empty state, binary file badge, “too large” error state.]
- The Explorer panel header contains a Refresh button (icon:
RefreshCwfrom lucide). Clicking it clears the per-path children cache in the Pinia store and re-fetches the root level. Subdirectories are then re-fetched on first expand. - When
tooLarge: trueis returned by the content endpoint, FE displays the error state: “Soubor je příliš velký pro zobrazení (500 KB limit)”.
State Management — Lazy Cache
The Pinia store explorer.ts keeps a per-path cache of children:
// pseudo-code
state: {
childrenByPath: Map<string, Entry[]> // key = relative path; "" = root
loadingPath: Set<string> // paths currently being fetched
}
action loadChildren(path: string) {
if (childrenByPath.has(path)) return // cache hit
if (loadingPath.has(path)) return // already in flight
loadingPath.add(path)
const res = await api.get(`/api/v1/projects/${slug}/files`, { params: { path } })
childrenByPath.set(path, res.entries)
loadingPath.delete(path)
}
action refresh() {
childrenByPath.clear()
loadingPath.clear()
loadChildren("")
}
- The initial mount of the Explorer panel calls
loadChildren(""). - Each directory node renders an expand/collapse toggle. On first expand, the node calls
loadChildren(node.path)and shows a per-node loading indicator until the response arrives. - Already-expanded nodes that are collapsed and re-expanded use the cached entries — no second fetch.
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
slug (path) | not_blank; kebab-case | 1 – 100 | ^[a-z0-9]+(-[a-z0-9]+)*$ | Taken from active project context; never entered manually |
path (query, list endpoint) | optional; if present, must not start with /, must use / separator | 0 – 4096 | — | URL-encoded; empty / absent means root level |
path (query, content endpoint) | not_blank; not start with /; use / separator | 1 – 4096 | — | Sent only when user clicks a non-binary file node |
Backend
Configuration
| Property | Description | Example |
|---|---|---|
talkide.output-dir | Absolute path on the BE server where all project directories live | /opt/talkide/projects |
talkide.explorer.max-file-size-bytes | Maximum file size for the content endpoint | 512000 (500 KB) |
talkide.explorer.whitelist | Hierarchical whitelist of visible top-level entries (see Whitelist) | see application.yaml |
Project root on disk: <talkide.output-dir>/<slug>/
Slug derivation utility (extracted from SendMessageUseCase.extractProjectSlug into a shared domain service):
- If
project.urlis not null and contains.: take substring before the first dot (e.g.wildwood-bakery.talkide.app→wildwood-bakery) - If
project.urlis null or has no dot: useproject.id.toString()
Validations
| Field | Constraints | Note |
|---|---|---|
slug (path) | not_blank; kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$); project must exist in DB and belong to user’s tenant | Direct DB lookup by slug column |
path (query, list endpoint) | optional; if present canonical resolved path must start with canonical project root (symlink-safe) and the first segment must satisfy the whitelist for its parent | 404 NOT_FOUND on any violation |
path (query, content endpoint) | not_blank; canonical resolved path must start with canonical project root (symlink-safe) | 400 INVALID_PATH if prefix check fails |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
Authenticated user, project exists and belongs to user’s tenant, project directory contains backend/, docs/, frontend/, plus CLAUDE.md and .project-config.yml | GET /api/v1/projects/{slug}/files (no path) is called | 200 OK; entries = backend, docs, frontend (DIRECTORY); CLAUDE.md and .project-config.yml absent; alphabetical order |
Authenticated user, project root also contains an extra file notes.txt and an extra dir tmp/ (not in whitelist) | GET /api/v1/projects/{slug}/files is called | 200 OK; notes.txt and tmp absent (root whitelist allows only backend, docs, frontend) |
| Authenticated user, project directory does not exist yet (first conversation never started) | GET /api/v1/projects/{slug}/files is called | 200 OK with path="" and empty entries array |
Authenticated user, ?path=frontend | GET /api/v1/projects/{slug}/files is called | 200 OK; entries restricted to whitelist for /frontend (src directory + the listed config files); other entries hidden |
Authenticated user, ?path=backend | GET /api/v1/projects/{slug}/files is called | 200 OK; entries = src only (whitelist for /backend) |
Authenticated user, ?path=docs | GET /api/v1/projects/{slug}/files is called | 200 OK; full directory listing (no whitelist entry → unrestricted) |
Authenticated user, ?path=frontend/src | GET /api/v1/projects/{slug}/files is called | 200 OK; full directory listing (no whitelist entry below */src → unrestricted) |
Authenticated user, ?path=frontend/src/components containing node_modules/ (defense-in-depth scenario) | GET /api/v1/projects/{slug}/files is called | 200 OK; node_modules absent (SKIPPED_DIRS blacklist) |
Authenticated user, ?path=backend/build (first segment build not in /backend whitelist) | GET /api/v1/projects/{slug}/files is called | 404 Not Found |
Authenticated user, ?path=tmp (first segment not in root whitelist) | GET /api/v1/projects/{slug}/files is called | 404 Not Found |
Authenticated user, ?path=../../etc (path traversal attack) | GET /api/v1/projects/{slug}/files is called | 404 Not Found |
Authenticated user, ?path=docs/nonexistent-dir | GET /api/v1/projects/{slug}/files is called | 404 Not Found |
| Authenticated user, directory contains symlink pointing outside project root | GET /api/v1/projects/{slug}/files is called | Symlink absent from entries |
| No Authorization header | GET /api/v1/projects/{slug}/files is called | 401 AUTHENTICATION_FAILED error response |
| Authenticated user, project belongs to a different tenant | GET /api/v1/projects/{slug}/files is called | 403 FORBIDDEN error response |
| Project slug does not match any project in DB | GET /api/v1/projects/{slug}/files is called | 404 NOT_FOUND error response |
Authenticated user, project exists, user clicks file frontend/src/main.ts (within 500 KB) | GET /api/v1/projects/{slug}/files/content?path=frontend/src/main.ts is called | 200 OK; tooLarge=false; UTF-8 content and correct sizeBytes returned |
| Authenticated user, file size is 600 KB | GET /api/v1/projects/{slug}/files/content?path=frontend/src/large.ts is called | 200 OK; tooLarge=true; sizeBytes=614400; no content field |
Authenticated user, path=../../etc/passwd (path traversal attack) | GET /api/v1/projects/{slug}/files/content is called | 400 Bad Request, code INVALID_PATH |
| Authenticated user, path points to a symlink whose target is outside project root | GET /api/v1/projects/{slug}/files/content is called | 400 Bad Request, code INVALID_PATH |
Authenticated user, path=CLAUDE.md (root-level filter) | GET /api/v1/projects/{slug}/files/content is called | 404 Not Found, code NOT_FOUND_FILE |
Authenticated user, path=.project-config.yml (root-level filter) | GET /api/v1/projects/{slug}/files/content is called | 404 Not Found, code NOT_FOUND_FILE |
Authenticated user, path=frontend/src/CLAUDE.md (subdirectory CLAUDE.md, not filtered) | GET /api/v1/projects/{slug}/files/content is called | 200 OK with file content |
Authenticated user, path=frontend/src/logo.png (binary file) | GET /api/v1/projects/{slug}/files/content is called | 400 Bad Request, code BINARY_FILE |
Authenticated user, path=nonexistent.txt | GET /api/v1/projects/{slug}/files/content is called | 404 Not Found, code NOT_FOUND_FILE |
Post-MVP / Future Work
- Syntax highlighting — File content Viewer currently renders plain monospace text. Add syntax highlighting (e.g. via highlight.js or Shiki) for common languages post-MVP.
- File encoding — Content endpoint reads UTF-8. Files encoded differently (e.g. ISO-8859-1) will appear as garbled text. Acceptable for MVP since generated project files are always UTF-8. Post-MVP: detect encoding via charset sniffing.
- WebSocket / SSE live refresh — MVP uses a manual Refresh button. Post-MVP: push file tree change events from BE to FE (e.g. via SSE) to auto-refresh when the agent writes new files.
- Search across the project tree — Lazy load is great for browsing but does not help when the user knows a file name and wants to jump to it. Post-MVP: server-side search endpoint that walks the (whitelisted, blacklisted) tree once and returns matching paths.
Thanks for the feedback.