u
This commit is contained in:
@@ -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/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` |
|
||||||
| [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` |
|
| [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` |
|
||||||
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /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/vfs/write.md`](routes/vfs/write.md) | `PUT /vfs/{*path}` |
|
||||||
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
||||||
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /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}` |
|
| `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}` |
|
| `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` |
|
||||||
| `stock` | `POST /stock/adjust` |
|
| `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}` |
|
| `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` |
|
||||||
|
|
||||||
## DESIGN
|
## DESIGN
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 206 API endpoints across all modules.
|
Overview of all 207 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -682,6 +682,9 @@ _none_
|
|||||||
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
||||||
- Params: `path: str, query: download?: bool, raw?: bool`
|
- Params: `path: str, query: download?: bool, raw?: bool`
|
||||||
- Returns: `200 str`
|
- 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}`
|
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
|
||||||
- Params: `path: str, body: { content: str, mime?: str }`
|
- Params: `path: str, body: { content: str, mime?: str }`
|
||||||
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
|
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
|
||||||
|
|||||||
77
docs/api/routes/vfs/exec.md
Normal file
77
docs/api/routes/vfs/exec.md
Normal file
@@ -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`._
|
||||||
@@ -89,7 +89,7 @@ function requireSelectedCompany(req) {
|
|||||||
* @param {string} raw
|
* @param {string} raw
|
||||||
* @returns {{ name: string } | { error: string }}
|
* @returns {{ name: string } | { error: string }}
|
||||||
*/
|
*/
|
||||||
function parseVfsRef(raw) {
|
export function parseVfsRef(raw) {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) {
|
if (typeof raw !== 'string' || !raw.trim()) {
|
||||||
return { error: 'path is required' };
|
return { error: 'path is required' };
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ function parseVfsRef(raw) {
|
|||||||
return { name: normalized, mime, canThumbnail, isInline };
|
return { name: normalized, mime, canThumbnail, isInline };
|
||||||
}
|
}
|
||||||
|
|
||||||
function vfsFileErrorStatus(error) {
|
export function vfsFileErrorStatus(error) {
|
||||||
if (error.message === 'No company selected') return 400;
|
if (error.message === 'No company selected') return 400;
|
||||||
if (error.message === 'Not authorized') return 403;
|
if (error.message === 'Not authorized') return 403;
|
||||||
if (error.message === 'File not found') return 404;
|
if (error.message === 'File not found') return 404;
|
||||||
|
|||||||
240
src/api/routes/vfsExec.js
Normal file
240
src/api/routes/vfsExec.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user