This commit is contained in:
seb
2026-10-07 20:36:50 +02:00
parent 857963b1c3
commit a29ad50b74
18 changed files with 1024 additions and 220 deletions

View File

@@ -207,7 +207,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
| [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` |
| [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` |
| [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` |
| [`routes/vfs/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` |
| [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` |
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
| [`routes/vfs/write.md`](routes/vfs/write.md) | `PUT /vfs/{*path}` |
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |
@@ -227,6 +233,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` |
## DESIGN

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 183 API endpoints across all modules.
Overview of all 189 API endpoints across all modules.
### Notation
@@ -575,9 +575,27 @@ _none_
#### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: str, limit?: int`
- Returns: `200 obj[]`
#### List virtual filesystem files — `GET /vfs`
_none_
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
- Params: `body: { path: str }`
- Returns: `200 { success: bool, dir: str }`
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
- Params: `body: { oldPath: str, newPath: str }`
- Returns: `200 { success: bool, oldPath: str, newPath: str }`
#### Upload files to virtual filesystem — `POST /vfs/upload`
- Params: `form: { files: str[], targetDir?: str }`
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
#### Delete a virtual filesystem file — `DELETE /vfs/{*path}`
- Params: `path: str`
- Returns: `200 { success: bool, deleted: str }`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str`
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
- Params: `path: str, body: { content: str, mime?: str }`
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T37[] }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 146 API endpoints across all modules.
Overview of all 145 API endpoints across all modules.
### Notation
@@ -460,9 +460,6 @@ _none_
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T34[] }`

View File

@@ -4,42 +4,70 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Get a virtual filesystem file
Returns a file the model produced in the `javascript_execute` sandbox
(virtual filesystem), for the authenticated user's selected company.
Returns a file from the virtual filesystem for the authenticated user's selected company.
Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users.
Embed the returned URL in chat markdown to display a generated image:
`![title](/api/vfs/example.png)`.
### URL Format
Accepts:
- Relative VFS paths: `example.png`, `reports/summary.pdf`
- Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped)
- Absolute URLs: `https://.../api/vfs/example.png`
- Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400.
Images are returned as a 250x250 aspect-preserving preview unless requested with
`download=1` or `raw=1`.
Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
inline so they open directly in the browser; other files are sent as downloads.
### Image Previews & Thumbnails
Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
- Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13).
- Images whose larger edge is below 100px are upscaled to 100px for icon legibility.
- A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`)
is rendered along the bottom edge, displaying original dimensions and file size.
- Served with `Content-Security-Policy: default-src 'none'; sandbox`.
- Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline.
### Inline vs Attachment
- Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM)
are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers.
- Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`.
- Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`.
### Caching & Conditional Requests
- Generates weak ETags (`W/"thumb-..."` for previews, `W/"<timestamp>-<mode>"` for originals).
- Sets `Cache-Control: private, max-age=0, must-revalidate`.
- Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | VFS file path (e.g. `example.png`, `labels/a.png`) |
| `path` | yes | string | VFS file path (e.g. `example.png`, `charts/graph.svg`) |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | Force file download with Content-Disposition: attachment.
|
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail.
|
| `download` | no | boolean | Force file download with Content-Disposition: attachment (1 or true). |
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail (1 or true). |
## Response `200`
File bytes
File bytes. Served inline for browser-renderable formats or as a download.
Includes headers:
- `Content-Type`: MIME type of file, or `image/webp` for previews
- `Content-Length`: Size of served payload in bytes
- `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."`
- `Cache-Control`: `private, max-age=0, must-revalidate`
- `ETag`: Weak ETag identifier
- `X-Content-Type-Options`: `nosniff`
- `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails)
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid path, or no company selected — `{"error":"…"}` |
| 304 | Not modified (If-None-Match matched ETag) |
| 400 | Invalid path, path escapes VFS boundary, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | File not found — `{"error":"…"}` |

View File

@@ -0,0 +1,42 @@
# `DELETE /vfs/{*path}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete a virtual filesystem file
Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | VFS file path to delete (e.g. `temp.png`, `notes.txt`) |
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
File successfully deleted
```json
{
"success": true,
"deleted": "…"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid path, path traversal, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot delete read-only database files — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,43 @@
# `GET /vfs`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List virtual filesystem files
Lists all virtual filesystem files for the authenticated user's selected company.
Scans directories recursively (ignoring hidden dotfiles) and returns file
metadata including path, byte size, detected MIME type, timestamps, and classification flags.
## Response `200`
List of virtual filesystem files
```json
{
"files": [
{
"name": "…",
"size": 1,
"mime": "…",
"updated_at": "2026-08-10T00:00:00.000Z",
"created_at": "2026-08-10T00:00:00.000Z",
"isText": true,
"isImage": true,
"isPdf": true
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,48 @@
# `POST /vfs/mkdir`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create directory in virtual filesystem
Creates a directory (and any necessary parent directories) in the virtual filesystem
for the authenticated user's selected company.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | Directory path to create (e.g. `reports/2026/q1`) |
```json
{
"path": "…"
}
```
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
Directory created successfully
```json
{
"success": true,
"dir": "…"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot create directory inside read-only database paths — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,52 @@
# `POST /vfs/rename`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Rename or move a virtual filesystem file or directory
Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company.
Parent directories for the destination path are created automatically if needed.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `oldPath` | yes | string | Existing VFS file or directory path (e.g. `draft.txt`) |
| `newPath` | yes | string | New destination VFS path (e.g. `archive/final.txt`) |
```json
{
"oldPath": "…",
"newPath": "…"
}
```
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
File or directory successfully renamed
```json
{
"success": true,
"oldPath": "…",
"newPath": "…"
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot rename read-only database paths — `{"error":"…"}` |
| 404 | Source file or directory not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,53 @@
# `POST /vfs/upload`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Upload files to virtual filesystem
Uploads one or more files to the virtual filesystem for the authenticated user's selected company.
Files are written to the company's VFS root directory and saved to the database.
An optional `targetDir` form field specifies a subfolder within the VFS.
Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is
forbidden and automatically skipped during upload.
## Body (`multipart/form-data`)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `files` | yes | file | One or more files to upload (form field name `files`) |
| `targetDir` | no | string | Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`) |
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
Successfully uploaded files
```json
{
"success": true,
"uploaded": [
{
"name": "…",
"size": 1,
"mime": "…"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No files uploaded, invalid path, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,66 @@
# `PUT /vfs/{*path}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create or overwrite a virtual filesystem file
Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company.
Supports multiple request payload formats:
- JSON body: `{"content": "text content", "mime": "optional/mime"}`
- Binary body: raw binary buffer with optional `Content-Type` header
- Plain text: raw UTF-8 string
Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`) |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `content` | yes | string | File text content |
| `mime` | no | string | Optional MIME type override |
```json
{
"content": "…",
"mime": "…"
}
```
## Signals
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
## Response `200`
File successfully written
```json
{
"success": true,
"file": {
"name": "…",
"size": 1,
"mime": "…",
"updated_at": "2026-08-10T00:00:00.000Z"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Path is required, invalid characters/traversal, no content provided, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized, or cannot modify read-only database files — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -50,6 +50,9 @@ function isRouteOmitted(method, apiPath) {
if (apiPath === '/public' || apiPath.startsWith('/public/')) {
return true;
}
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
return true;
}
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
}

View File

@@ -1271,6 +1271,36 @@
* type: string
* sort:
* type: integer
* VfsFile:
* type: object
* required: [name, size, mime, updated_at, created_at, isText, isImage, isPdf]
* properties:
* name:
* type: string
* description: Relative file path within the virtual filesystem
* size:
* type: integer
* description: File size in bytes
* mime:
* type: string
* description: Detected or declared MIME type
* updated_at:
* type: string
* format: date-time
* description: ISO timestamp of last modification
* created_at:
* type: string
* format: date-time
* description: ISO timestamp of creation
* isText:
* type: boolean
* description: True if file is text, code, JSON, Markdown, or tabular data
* isImage:
* type: boolean
* description: True if file is a supported raster image eligible for preview thumbnailing
* isPdf:
* type: boolean
* description: True if file is a PDF document
*/
export {};

View File

@@ -46,6 +46,7 @@ export const openApiOptions = {
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
{ name: 'PublicCatalog', description: 'Public product catalog API' },
{ name: 'Vfs', description: 'Virtual filesystem file management and preview' },
],
},
apis: [

View File

@@ -2,10 +2,8 @@ import fs from 'fs';
import path from 'path';
import multer from 'multer';
import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js';
import * as db from '../../db/modelvfs.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
import sharp from 'sharp';
import { getMimeType } from '../../tools/vfs.js';
// Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
@@ -379,61 +377,32 @@ export function register(app, basePath = '') {
/**
* @openapi
* /vfs/{*path}:
* /vfs:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* summary: List virtual filesystem files
* description: |
* Returns a file the model produced in the `javascript_execute` sandbox
* (virtual filesystem), for the authenticated user's selected company.
*
* Embed the returned URL in chat markdown to display a generated image:
* `![title](/api/vfs/example.png)`.
*
* Images are returned as a 250x250 aspect-preserving preview unless requested with
* `download=1` or `raw=1`.
* Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
* inline so they open directly in the browser; other files are sent as downloads.
* Lists all virtual filesystem files for the authenticated user's selected company.
* Scans directories recursively (ignoring hidden dotfiles) and returns file
* metadata including path, byte size, detected MIME type, timestamps, and classification flags.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: |
* Force file download with Content-Disposition: attachment.
* - in: query
* name: raw
* required: false
* schema:
* type: boolean
* description: |
* Return original unmodified image inline without generating a preview thumbnail.
* x-markdown: vfs/list.md
* responses:
* 200:
* description: File bytes
* headers:
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* description: List of virtual filesystem files
* content:
* application/octet-stream:
* application/json:
* schema:
* type: string
* format: binary
* type: object
* required: [files]
* properties:
* files:
* type: array
* items:
* $ref: '#/components/schemas/VfsFile'
* 400:
* description: Invalid path, or no company selected
* description: No company selected
* content:
* application/json:
* schema:
@@ -450,14 +419,7 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/vfs`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -506,6 +468,85 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/upload:
* post:
* tags: [Vfs]
* summary: Upload files to virtual filesystem
* description: |
* Uploads one or more files to the virtual filesystem for the authenticated user's selected company.
* Files are written to the company's VFS root directory and saved to the database.
* An optional `targetDir` form field specifies a subfolder within the VFS.
*
* Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is
* forbidden and automatically skipped during upload.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/upload.md
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required: [files]
* properties:
* files:
* type: array
* items:
* type: string
* format: binary
* description: One or more files to upload (form field name `files`)
* targetDir:
* type: string
* description: Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`)
* responses:
* 200:
* description: Successfully uploaded files
* content:
* application/json:
* schema:
* type: object
* required: [success, uploaded]
* properties:
* success:
* type: boolean
* uploaded:
* type: array
* items:
* type: object
* required: [name, size, mime]
* properties:
* name:
* type: string
* description: Stored relative path in VFS
* size:
* type: integer
* description: Size in bytes
* mime:
* type: string
* description: Effective MIME type
* 400:
* description: No files uploaded, invalid path, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -531,6 +572,7 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) continue;
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) continue;
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, f.buffer);
@@ -538,12 +580,6 @@ export function register(app, basePath = '') {
? f.mimetype
: getMimeType(parsed.name);
await db.write(req.user.id, {
name: parsed.name,
mime: effectiveMime,
content: f.buffer,
});
uploaded.push({
name: parsed.name,
size: f.buffer.length,
@@ -560,6 +596,305 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/mkdir:
* post:
* tags: [Vfs]
* summary: Create directory in virtual filesystem
* description: |
* Creates a directory (and any necessary parent directories) in the virtual filesystem
* for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/mkdir.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [path]
* properties:
* path:
* type: string
* description: Directory path to create (e.g. `reports/2026/q1`)
* responses:
* 200:
* description: Directory created successfully
* content:
* application/json:
* schema:
* type: object
* required: [success, dir]
* properties:
* success:
* type: boolean
* dir:
* type: string
* description: Created directory path
* 400:
* description: Path is required, invalid characters, traversal, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized, or cannot create directory inside read-only database paths
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/vfs/mkdir`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const raw = req.body?.path || req.body?.dir || req.body?.name;
const parsed = parseVfsRef(raw);
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
fs.mkdirSync(absPath, { recursive: true });
emit('vfs', { companyId: selected.companyId });
return res.json({ success: true, dir: parsed.name });
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Create vfs directory failed:', error);
return res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /vfs/rename:
* post:
* tags: [Vfs]
* summary: Rename or move a virtual filesystem file or directory
* description: |
* Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company.
* Parent directories for the destination path are created automatically if needed.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/rename.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [oldPath, newPath]
* properties:
* oldPath:
* type: string
* description: Existing VFS file or directory path (e.g. `draft.txt`)
* newPath:
* type: string
* description: New destination VFS path (e.g. `archive/final.txt`)
* responses:
* 200:
* description: File or directory successfully renamed
* content:
* application/json:
* schema:
* type: object
* required: [success, oldPath, newPath]
* properties:
* success:
* type: boolean
* oldPath:
* type: string
* newPath:
* type: string
* 400:
* description: Path is required, invalid characters, traversal, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized, or cannot rename read-only database paths
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Source file or directory not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/vfs/rename`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawOld = req.body?.oldPath || req.body?.from || req.body?.source;
const rawNew = req.body?.newPath || req.body?.to || req.body?.target;
const parsedOld = parseVfsRef(rawOld);
if (parsedOld.error) {
return res.status(400).json({ error: `oldPath: ${parsedOld.error}` });
}
const parsedNew = parseVfsRef(rawNew);
if (parsedNew.error) {
return res.status(400).json({ error: `newPath: ${parsedNew.error}` });
}
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absOld = path.resolve(vfsRoot, parsedOld.name);
const absNew = path.resolve(vfsRoot, parsedNew.name);
if (!absOld.startsWith(vfsRoot) || !absNew.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
if (!fs.existsSync(absOld)) {
return res.status(404).json({ error: 'Source file or directory not found' });
}
fs.mkdirSync(path.dirname(absNew), { recursive: true });
fs.renameSync(absOld, absNew);
emit('vfs', { companyId: selected.companyId });
return res.json({
success: true,
oldPath: parsedOld.name,
newPath: parsedNew.name,
});
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Rename vfs file failed:', error);
return res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /vfs/{*path}:
* put:
* tags: [Vfs]
* summary: Create or overwrite a virtual filesystem file
* description: |
* Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company.
* Supports multiple request payload formats:
* - JSON body: `{"content": "text content", "mime": "optional/mime"}`
* - Binary body: raw binary buffer with optional `Content-Type` header
* - Plain text: raw UTF-8 string
*
* Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/write.md
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [content]
* properties:
* content:
* type: string
* description: File text content
* mime:
* type: string
* description: Optional MIME type override
* application/octet-stream:
* schema:
* type: string
* format: binary
* description: Raw binary payload
* text/plain:
* schema:
* type: string
* description: Raw text payload
* responses:
* 200:
* description: File successfully written
* content:
* application/json:
* schema:
* type: object
* required: [success, file]
* properties:
* success:
* type: boolean
* file:
* type: object
* required: [name, size, mime, updated_at]
* properties:
* name:
* type: string
* description: VFS file path
* size:
* type: integer
* description: Written byte length
* mime:
* type: string
* description: Stored MIME type
* updated_at:
* type: string
* format: date-time
* description: ISO timestamp of update
* 400:
* description: Path is required, invalid characters/traversal, no content provided, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized, or cannot modify read-only database files
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -593,15 +928,12 @@ export function register(app, basePath = '') {
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, buffer);
await db.write(req.user.id, {
name: parsed.name,
mime,
content: buffer,
});
emit('vfs', { companyId: selected.companyId });
return res.json({
success: true,
@@ -619,6 +951,59 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/{*path}:
* delete:
* tags: [Vfs]
* summary: Delete a virtual filesystem file
* description: |
* Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
* Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
* security:
* - cookieAuth: []
* x-signals: [vfs]
* x-markdown: vfs/delete.md
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path to delete (e.g. `temp.png`, `notes.txt`)
* responses:
* 200:
* description: File successfully deleted
* content:
* application/json:
* schema:
* type: object
* required: [success, deleted]
* properties:
* success:
* type: boolean
* deleted:
* type: string
* description: Path of deleted file
* 400:
* description: Invalid path, path traversal, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized, or cannot delete read-only database files
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.delete(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -636,10 +1021,18 @@ export function register(app, basePath = '') {
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (fs.existsSync(absPath)) {
try { fs.unlinkSync(absPath); } catch {}
if (!absPath.startsWith(vfsRoot)) {
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
}
if (!fs.existsSync(absPath)) {
return res.status(404).json({ error: 'File not found' });
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
fs.rmSync(absPath, { recursive: true, force: true });
} else {
fs.unlinkSync(absPath);
}
await db.remove(req.user.id, parsed.name);
emit('vfs', { companyId: selected.companyId });
return res.json({ success: true, deleted: parsed.name });
@@ -650,6 +1043,131 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /vfs/{*path}:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* description: |
* Returns a file from the virtual filesystem for the authenticated user's selected company.
* Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users.
*
* ### URL Format
* Accepts:
* - Relative VFS paths: `example.png`, `reports/summary.pdf`
* - Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped)
* - Absolute URLs: `https://.../api/vfs/example.png`
* - Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400.
*
* ### Image Previews & Thumbnails
* Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
* are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
* - Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13).
* - Images whose larger edge is below 100px are upscaled to 100px for icon legibility.
* - A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`)
* is rendered along the bottom edge, displaying original dimensions and file size.
* - Served with `Content-Security-Policy: default-src 'none'; sandbox`.
* - Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline.
*
* ### Inline vs Attachment
* - Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM)
* are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers.
* - Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`.
* - Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`.
*
* ### Caching & Conditional Requests
* - Generates weak ETags (`W/"thumb-..."` for previews, `W/"<timestamp>-<mode>"` for originals).
* - Sets `Cache-Control: private, max-age=0, must-revalidate`.
* - Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`.
* security:
* - cookieAuth: []
* x-markdown: vfs/_*path.md
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path (e.g. `example.png`, `charts/graph.svg`)
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: 'Force file download with Content-Disposition: attachment (1 or true).'
* - in: query
* name: raw
* required: false
* schema:
* type: boolean
* description: 'Return original unmodified image inline without generating a preview thumbnail (1 or true).'
* responses:
* 200:
* description: |
* File bytes. Served inline for browser-renderable formats or as a download.
* Includes headers:
* - `Content-Type`: MIME type of file, or `image/webp` for previews
* - `Content-Length`: Size of served payload in bytes
* - `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."`
* - `Cache-Control`: `private, max-age=0, must-revalidate`
* - `ETag`: Weak ETag identifier
* - `X-Content-Type-Options`: `nosniff`
* - `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails)
* headers:
* Content-Type:
* schema:
* type: string
* Content-Length:
* schema:
* type: string
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* ETag:
* schema:
* type: string
* X-Content-Type-Options:
* schema:
* type: string
* Content-Security-Policy:
* schema:
* type: string
* content:
* application/octet-stream:
* schema:
* type: string
* format: binary
* 304:
* description: Not modified (If-None-Match matched ETag)
* 400:
* description: Invalid path, path escapes VFS boundary, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
@@ -663,15 +1181,20 @@ export function register(app, basePath = '') {
}
try {
const file = await db.read(req.user.id, parsed.name);
if (!file?.content?.length) {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) {
return res.status(404).json({ error: 'File not found' });
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
return res.status(400).json({ error: 'Cannot download a directory directly' });
}
const content = fs.readFileSync(absPath);
const updatedAt = stat.mtime.toISOString();
const fallbackName = parsed.name.split('/').pop() || 'download';
const effectiveMime = (file.mime && file.mime !== 'application/octet-stream')
? file.mime
: getMimeType(parsed.name);
const effectiveMime = getMimeType(parsed.name);
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
const wantsRaw = req.query.raw === '1' || req.query.raw === 'true';
@@ -681,7 +1204,7 @@ export function register(app, basePath = '') {
// unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
if (shouldThumbnail) {
const rendered = await renderThumbnail(file.content, {
const rendered = await renderThumbnail(content, {
mime: effectiveMime,
filename: fallbackName,
}).catch((err) => {
@@ -696,7 +1219,7 @@ export function register(app, basePath = '') {
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
// Previews are derived data — never cache them immutably, the source file can change.
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
res.setHeader('ETag', `W/"thumb-${updatedAt || parsed.name}"`);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
return res.status(304).end();
@@ -707,11 +1230,11 @@ export function register(app, basePath = '') {
}
res.setHeader('Content-Type', effectiveMime);
res.setHeader('Content-Length', String(file.content.length));
res.setHeader('Content-Length', String(content.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
// Mutable VFS artifacts are cached with revalidation
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"${file.updated_at || file.name}-${isInline ? 'i' : 'a'}"`);
res.setHeader('ETag', `W/"${updatedAt || parsed.name}-${isInline ? 'i' : 'a'}"`);
res.setHeader(
'Content-Disposition',
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
@@ -722,7 +1245,7 @@ export function register(app, basePath = '') {
return res.status(304).end();
}
return res.status(200).send(file.content);
return res.status(200).send(content);
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Get vfs file failed:', error);

View File

@@ -535,16 +535,6 @@ export class ConversationManager {
} catch (err) {
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, err);
}
try {
await store.modelvfs.write(googleAuthId, {
name: vfsRelPath,
mime,
content: fileBuffer,
});
} catch (err) {
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment to modelvfs:`, err);
}
}
const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);

View File

@@ -1,7 +1,6 @@
import fs from 'fs';
import path from 'path';
import { defineTool } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isSkillPath } from './skill.js';
import { resolveVfsRoot } from './vfs.js';
@@ -40,25 +39,8 @@ export function createResetVfsTool({ googleAuthId }) {
},
},
invoke: async ({ include_skills = false } = {}) => {
const store = await getStore();
const keepSkills = include_skills !== true;
const keptSkills = [];
const existing = await store.modelvfs.list(googleAuthId);
// Phase 1 — clear the database, but only for the names we may delete. Done before
// touching disk so a store failure cannot leave the disk wiped while the database
// still claims the files exist.
for (const file of existing) {
if (keepSkills && isSkillPath(file.name)) {
keptSkills.push(file.name);
continue;
}
await store.modelvfs.delete(googleAuthId, file.name);
}
// Phase 2 — mirror the same decision onto the on-disk cache. The root comes from
// the same lookup the store used above, so the two phases cannot drift apart.
const vfsRoot = await resolveVfsRoot(googleAuthId);
let deletedCount = 0;
if (fs.existsSync(vfsRoot)) {

View File

@@ -1,7 +1,6 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
import { isSkillPath } from './skill.js';
@@ -97,18 +96,12 @@ export function createRmTool({ googleAuthId }) {
const vfsRoot = await prepareVfs({ googleAuthId });
const { abs, size } = deleteVfsFile(vfsRoot, clean);
// Database first. If this throws, the file is still on disk — recoverable, and the row
// still exists to describe it. Doing it the other way round would leave a blob on
// disk that the next prepareVfs hydration would restore, silently undoing the delete.
const store = await getStore();
const res = await store.modelvfs.delete(googleAuthId, clean);
fs.unlinkSync(abs);
const wasSkill = isSkillPath(clean);
const result = {
success: true,
deleted: res?.deleted !== false,
deleted: true,
filename: clean,
size,
message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`,

View File

@@ -126,7 +126,7 @@ async function vfsRootFor(store, googleAuthId) {
}
/**
* Loads default SKILL files into the selected company's VFS (both modelvfs table and disk cache).
* Loads default SKILL files into the selected company's on-disk VFS.
*
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
* @returns {Promise<Array<string>>} List of loaded skill file names
@@ -140,19 +140,12 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR
const loaded = [];
for (const skill of defaultSkills) {
try {
await store.modelvfs.write(googleAuthId, {
name: skill.name,
mime: 'text/markdown',
content: Buffer.from(skill.content, 'utf8'),
});
} catch (err) {
console.error(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
}
try {
const diskPath = path.resolve(vfsRoot, skill.name);
fs.writeFileSync(diskPath, skill.content, 'utf8');
if (!fs.existsSync(diskPath)) {
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
fs.writeFileSync(diskPath, skill.content, 'utf8');
}
loaded.push(skill.name);
} catch (err) {
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message);
@@ -178,7 +171,7 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot
}
/**
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
* Ensure the local directory for this company's VFS exists and default skills are loaded.
*
* @param {{ googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path
@@ -189,86 +182,21 @@ export async function prepareVfs({ googleAuthId }) {
fs.mkdirSync(vfsRoot, { recursive: true });
try {
const files = await store.modelvfs.list(googleAuthId);
const existingNames = new Set(files.map((f) => f.name));
const defaultSkills = getDefaultSkills();
for (const skill of defaultSkills) {
if (!existingNames.has(skill.name)) {
try {
await store.modelvfs.write(googleAuthId, {
name: skill.name,
mime: 'text/markdown',
content: Buffer.from(skill.content, 'utf8'),
});
files.push({ name: skill.name });
existingNames.add(skill.name);
} catch (err) {
console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
}
}
}
for (const file of files) {
const diskPath = path.resolve(vfsRoot, file.name);
if (!diskPath.startsWith(vfsRoot)) continue;
if (!fs.existsSync(diskPath)) {
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
const fullRecord = await store.modelvfs.read(googleAuthId, file.name);
if (fullRecord?.content) {
fs.writeFileSync(diskPath, fullRecord.content);
}
}
}
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
} catch (err) {
console.warn('[vfs] hydration warning:', err.message);
console.warn('[vfs] prepare warning:', err.message);
}
return vfsRoot;
}
/**
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
* Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility.
*
* The store re-scopes by the currently selected company on every call, so this only stays
* correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn.
*
* @param {{ googleAuthId: number, vfsRoot: string }} ctx
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
*/
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
if (!fs.existsSync(vfsRoot)) return;
const store = await getStore();
function walk(dir, rel = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const list = [];
for (const e of entries) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
const fullPath = path.join(dir, e.name);
if (e.isDirectory()) {
list.push(...walk(fullPath, relPath));
} else if (e.isFile()) {
list.push({ relPath, fullPath });
}
}
return list;
}
const currentFiles = walk(vfsRoot);
for (const f of currentFiles) {
if (isReadOnlyDatabasePath(f.relPath)) continue;
try {
const content = fs.readFileSync(f.fullPath);
const mime = getMimeType(f.relPath);
await store.modelvfs.write(googleAuthId, {
name: f.relPath,
mime,
content,
});
} catch (err) {
console.error(`[vfs] failed to sync file ${f.relPath} to db:`, err.message);
}
}
export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
// Purely disk-based VFS; no database syncing.
}
/**