From eed10ea38d6dbf4b7b720a9dd9b83b9daf052415 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 10 Oct 2026 05:52:15 +0200 Subject: [PATCH] u --- docs/api/README.md | 3 +- docs/api/routes-all.md | 5 +- docs/api/routes/vfs/exec.md | 77 ++++++++++++ src/api/routes/vfs.js | 4 +- src/api/routes/vfsExec.js | 240 ++++++++++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 docs/api/routes/vfs/exec.md create mode 100644 src/api/routes/vfsExec.js diff --git a/docs/api/README.md b/docs/api/README.md index 69cb9c1..ae2bef4 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -230,6 +230,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`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/exec.md`](routes/vfs/exec.md) | `POST /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` | @@ -259,7 +260,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` | | `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` | | `stock` | `POST /stock/adjust` | -| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` | +| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` | | `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` | ## DESIGN diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 667059b..7a58a12 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 206 API endpoints across all modules. +Overview of all 207 API endpoints across all modules. ### Notation @@ -682,6 +682,9 @@ _none_ #### Get a virtual filesystem file — `GET /vfs/{*path}` - Params: `path: str, query: download?: bool, raw?: bool` - Returns: `200 str` +#### Execute a virtual filesystem applet — `POST /vfs/{*path}` +- Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }` +- Returns: `200 { ok: bool, result: any|null, stdout: str, consoleLines: int, durationMs: int, truncated?: bool, truncationNote?: 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 } }` diff --git a/docs/api/routes/vfs/exec.md b/docs/api/routes/vfs/exec.md new file mode 100644 index 0000000..62edeb5 --- /dev/null +++ b/docs/api/routes/vfs/exec.md @@ -0,0 +1,77 @@ +# `POST /vfs/{*path}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Execute a virtual filesystem applet + +Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company +and returns the exported function's result. + +The applet file must begin with two metadata lines: +`// @applet-input-schema {JSON Schema}` followed by +`// @applet-output-schema {JSON Schema}`. The file exports a function that receives the posted `input` +as `applet_input`, and execution runs in the same isolated worker sandbox as the `javascript_execute` +tool — including authenticated `fetch()` against this API, `fs`/`vfs`, `sharp`, ExcelJS, PDFKit and +WebCrypto globals. + +The path is taken from the URL (like `DELETE /vfs/{*path}`); the run arguments are the JSON body. +Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does +not end in `.applet.js`. Read-only database paths are rejected with HTTP 403. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | VFS-relative path to the applet file (e.g. `demo.applet.js`, `apps/report.applet.js`) | + +## Body (JSON) — _optional_ + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `input` | no | object | Input values passed to the exported applet function as `applet_input`. Defaults to an empty object. | +| `timeout` | no | integer | Execution timeout in milliseconds, clamped to 1000…60000. Default 20000. | +| `max_output` | no | integer | Maximum stdout characters before truncation. Default 20000. | + +```json +{ + "input": {}, + "timeout": 1, + "max_output": 1 +} +``` + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +Applet executed successfully + +```json +{ + "ok": true, + "result": null, + "stdout": "…", + "consoleLines": 1, + "durationMs": 1, + "truncated": true, + "truncationNote": "…" +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected, invalid path, not a .applet.js file, invalid input, or invalid applet metadata — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized, or cannot execute read-only database paths — `{"error":"…"}` | +| 404 | Applet not found — `{"error":"…"}` | +| 500 | The applet threw during execution — `{"error":"…"}` | +| 504 | The applet exceeded the execution timeout and was terminated — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index 0ec7018..c75e3bc 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -89,7 +89,7 @@ function requireSelectedCompany(req) { * @param {string} raw * @returns {{ name: string } | { error: string }} */ -function parseVfsRef(raw) { +export function parseVfsRef(raw) { if (typeof raw !== 'string' || !raw.trim()) { return { error: 'path is required' }; } @@ -136,7 +136,7 @@ function parseVfsRef(raw) { return { name: normalized, mime, canThumbnail, isInline }; } -function vfsFileErrorStatus(error) { +export function vfsFileErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'File not found') return 404; diff --git a/src/api/routes/vfsExec.js b/src/api/routes/vfsExec.js new file mode 100644 index 0000000..bcf8959 --- /dev/null +++ b/src/api/routes/vfsExec.js @@ -0,0 +1,240 @@ +import fs from 'fs'; +import path from 'path'; +import { emit } from '../sse.js'; +import { isReadOnlyDatabasePath, resolveVfsRoot } from '../../tools/vfs.js'; +import { createJavascriptExecuteTool } from '../../tools/javascript.js'; +import { parseVfsRef, vfsFileErrorStatus } from './vfs.js'; + +// #region Helpers + +// VFS paths that are owned by their own literal route in `vfs.js`. They are registered as +// separate Express handlers, so this wildcard must defer to them instead of treating e.g. +// `/vfs/upload` as an applet path. Deferring keeps the two files order-independent. +const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename']); + +function requireSelectedCompany(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +/** + * Map applet execution failures onto HTTP status codes, matching the per-route mapper + * convention used across `src/api/routes`. + * + * The sandbox reports its own failures as `ToolError`s whose message text is the contract; keep + * these patterns in sync with `src/tools/javascript.js` (`readApplet` validation, timeout, and + * `formatSandboxError`). + * + * @param {Error} error + * @returns {number} + */ +function appletExecErrorStatus(error) { + const message = error?.message || ''; + if (message === 'No company selected') return 400; + if (message === 'Not authorized') return 403; + if (message === 'File not found') return 404; + if (message.includes('must refer to a .applet.js file')) return 400; + if (message.includes('must be a VFS-relative file path')) return 400; + if (message.includes('must be an object')) return 400; + if (message.includes('Unable to read applet') || message.includes('ENOENT')) return 404; + if (message.includes('is not a file')) return 400; + if (message.includes('Applet metadata') || message.includes('Applet input schema') || message.includes('Applet output schema')) return 400; + if (message.includes('Sandbox exceeded')) return 504; + return 500; +} + +// #endregion + +/** + * Registers the virtual filesystem applet execution route (requires auth). + * + * Runs a `.applet.js` file that lives in the VFS, mirroring `DELETE /vfs/{*path}`: the path is + * the URL, the run arguments are the body. Execution is delegated to the `javascript_execute` + * tool's `invoke`, so the sandbox, authenticated `fetch()`, JWT scoping, VFS hydration, and + * stdout/timeout limits behave exactly as they do for the model. + * + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /vfs/{*path}: + * post: + * tags: [Vfs] + * summary: Execute a virtual filesystem applet + * description: | + * Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company + * and returns the exported function's result. + * + * The applet file must begin with two metadata lines: + * `// @applet-input-schema {JSON Schema}` followed by + * `// @applet-output-schema {JSON Schema}`. The file exports a function that receives the posted `input` + * as `applet_input`, and execution runs in the same isolated worker sandbox as the `javascript_execute` + * tool — including authenticated `fetch()` against this API, `fs`/`vfs`, `sharp`, ExcelJS, PDFKit and + * WebCrypto globals. + * + * The path is taken from the URL (like `DELETE /vfs/{*path}`); the run arguments are the JSON body. + * Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does + * not end in `.applet.js`. Read-only database paths are rejected with HTTP 403. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/exec.md + * parameters: + * - in: path + * name: path + * required: true + * schema: + * type: string + * description: VFS-relative path to the applet file (e.g. `demo.applet.js`, `apps/report.applet.js`) + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * input: + * type: object + * description: Input values passed to the exported applet function as `applet_input`. Defaults to an empty object. + * additionalProperties: true + * timeout: + * type: integer + * description: 'Execution timeout in milliseconds, clamped to 1000…60000. Default 20000.' + * max_output: + * type: integer + * description: 'Maximum stdout characters before truncation. Default 20000.' + * responses: + * 200: + * description: Applet executed successfully + * content: + * application/json: + * schema: + * type: object + * required: [ok, result, stdout, consoleLines, durationMs] + * properties: + * ok: + * type: boolean + * result: + * description: The value returned by the applet's exported function + * nullable: true + * stdout: + * type: string + * description: Captured console output + * consoleLines: + * type: integer + * durationMs: + * type: integer + * truncated: + * type: boolean + * description: Present when stdout was capped at `max_output` + * truncationNote: + * type: string + * 400: + * description: No company selected, invalid path, not a .applet.js file, invalid input, or invalid applet metadata + * 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 execute read-only database paths + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Applet not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: The applet threw during execution + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 504: + * description: The applet exceeded the execution timeout and was terminated + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region POST /vfs/*path + + app.post(`${basePath}/vfs/*path`, async (req, res, next) => { + const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path; + // Let the literal `/vfs/upload`, `/vfs/mkdir`, and `/vfs/rename` routes handle their + // own POSTs regardless of which file Express loaded first. + const firstSegment = String(raw || '').split('/').filter(Boolean)[0]?.toLowerCase(); + if (firstSegment && RESERVED_VFS_SEGMENTS.has(firstSegment)) { + return next(); + } + + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + 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 execute read-only database files' }); + } + if (!parsed.name.toLowerCase().endsWith('.applet.js')) { + return res.status(400).json({ error: 'path must refer to a .applet.js file' }); + } + + const input = req.body?.input ?? {}; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return res.status(400).json({ error: 'input must be an object' }); + } + const timeout = req.body?.timeout; + const maxOutput = req.body?.max_output; + + // Fail fast with a clean 404 before the sandbox reports a wrapped read error. + try { + const vfsRoot = await resolveVfsRoot(req.user.id); + const absPath = path.resolve(vfsRoot, parsed.name); + if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) { + return res.status(404).json({ error: 'File not found' }); + } + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Execute vfs applet failed:', error); + return res.status(status).json({ error: error.message }); + } + + try { + const tool = createJavascriptExecuteTool({ googleAuthId: req.user.id }); + const result = await tool.invoke({ + appletvfsfile: parsed.name, + applet_input: input, + timeout, + max_output: maxOutput, + }); + + emit('vfs', { companyId: selected.companyId }); + return res.json(result); + } catch (error) { + const status = appletExecErrorStatus(error); + if (status === 500) console.error('Execute vfs applet failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + // #endregion +}