This commit is contained in:
seb
2026-10-09 19:02:15 +02:00
parent cd6879f3b7
commit ac10840fb6
42 changed files with 467 additions and 49 deletions

View File

@@ -246,11 +246,20 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| Signal | Emitted by |
|--------|------------|
| `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}`, `DELETE /category/{id}` |
| `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`, `POST /stock/adjust` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `POST /stock/adjust` |
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `POST /stock/adjust` |
| `modelconversation` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `POST /modelconversation/{id}/message` |
| `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}` |
| `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` |
## DESIGN

View File

@@ -214,8 +214,8 @@ _none_
- Params: `body: { companyId: int }`
- Returns: `200 { user: T34 }`
#### List brands — `GET /brand`
- Params: `query: offset?: int, limit?: int`
- Returns: `200 { brands: { id: int, name: str, pics: T36[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool`
- Returns: `200 { brands: { id: int, name: str, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }`
@@ -327,6 +327,15 @@ _none_
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
- Events:
- `status`: `{ "message": "…" }`
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
- `tool_call`: `{ "name": "output_manifest" }`
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
- `progress`: `ManifestResult`
- `result`: `ManifestResult`
- `error`: `{ "message": "…" }`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -384,6 +393,22 @@ _none_
#### Subscribe to update signals (SSE) — `GET /events`
_none_
- Returns: `200 SSE`
- Events:
- `category`: `{}`
- `articles`: `{}`
- `article-<sku>`: `{}`
- `stock`: `{}`
- `ledger`: `{}`
- `attributename`: `{}`
- `propertyname`: `{}`
- `labeltemplate`: `{}`
- `printer`: `{}`
- `warehouse`: `{}`
- `vfs`: `{}`
- `modelconversation`: `{}`
- `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }`
#### Database health check — `GET /health`
_none_
- Returns: `200 { ok: bool }`
@@ -573,6 +598,12 @@ _none_
#### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images`
- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }`
- Returns: `200 SSE`
- Events:
- `images`: `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }`
- `thumb`: `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }`
- `done`: `{ "total": 100 }`
- `error`: `{ "error": "…" }`
#### Delete shipping — `DELETE /shipping/{id}`
- Params: `id: int`
- Returns: `204 void`

View File

@@ -197,8 +197,8 @@ _none_
_none_
- Returns: `200 { message: str }`
#### List brands — `GET /brand`
- Params: `query: offset?: int, limit?: int`
- Returns: `200 { brands: { id: int, name: str, pics: T32[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool`
- Returns: `200 { brands: { id: int, name: str, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T32[], gsprpinfo?: str|null } }`

View File

@@ -22,6 +22,10 @@ Creates an attribute name for the selected company.
}
```
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `201`
Created attribute name

View File

@@ -12,6 +12,10 @@ Deletes an attribute name and cascades to attribute values and article assignmen
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Deleted successfully

View File

@@ -28,6 +28,10 @@ Updates name, sort, and/or exclusive flag for an attribute name.
}
```
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Updated attribute name

View File

@@ -20,6 +20,10 @@ Updates the display sort order of attribute names for the selected company.
}
```
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Reordered successfully

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
List brands
Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count.
Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 40px maximum edge).
## Query parameters
@@ -12,6 +12,7 @@ Lists brands for the authenticated user's selected company, sorted by name. Page
|-------|----------|------|-------------|
| `offset` | no | integer | Number of brands to skip |
| `limit` | no | integer | Number of brands to return |
| `includeSmallThumbs` | no | boolean | Include each picture's small thumbnail dimensions and base64 AVIF bytes. |
## Response `200`
@@ -29,7 +30,10 @@ Brand list
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
"thumbHash": "…",
"smallThumbW": 1,
"smallThumbH": 1,
"smallThumbBinary": "…"
}
]
}
@@ -44,7 +48,7 @@ Brand list
| Status | Description |
|--------|-------------|
| 400 | Invalid offset, invalid limit, or no company selected — `{"error":"…"}` |
| 400 | Invalid offset, invalid limit, invalid includeSmallThumbs, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |

View File

@@ -24,21 +24,36 @@ to the company at connect time).
## Signals
| Signal | Emitted after successful |
|--------|--------------------------|
| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |
| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |
| `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |
| `brand` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
Every SSE event uses the event name as the signal id; payloads are
JSON and most events send an empty object `{}`. Only `brand` emits a
richer payload.
The `brand` signal carries a JSON payload `{ sender, brandId }`:
`sender` echoes the caller's `x-client-id` header (empty string if
absent) so the sender can ignore its own event; `brandId` is the
affected brand id (`null` only if unknown). Other signals send `{}`.
| Signal | Payload | Emitted after successful |
|--------|---------|--------------------------|
| `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` |
| `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations |
| `article-<sku>` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article |
| `stock` | `{}` | `POST /stock/adjust` |
| `ledger` | `{}` | `POST /stock/adjust` |
| `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` |
| `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` |
| `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` |
| `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
| `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
| `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
| `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
The `brand` payload is `{ sender, brandId }`: `sender` echoes the
caller's `x-client-id` header (empty string if absent) so the sender
can ignore its own event; `brandId` is the affected brand id (`null`
only if unknown). All other signals send `{}`.
Signals are only sent when the mutation actually succeeds (2xx).
Failed or no-op validation errors do not emit. The `article-<sku>`
event name uses the concrete SKU (e.g. `article-42000045`).
and `modelconversation-<id>` event names use the concrete item id at
runtime (for example `article-42000045` and `modelconversation-123`).
## Response `200`

View File

@@ -46,6 +46,10 @@ and stored with the template.
}
```
## Signals
On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)).
## Response `201`
Created label template

View File

@@ -12,6 +12,10 @@ Deletes a label template for the authenticated user's selected company.
|-------|----------|------|-------------|
| `id` | yes | integer | Label template ID |
## Signals
On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)).
## Response `204`
Label template deleted

View File

@@ -51,6 +51,10 @@ When `content` (or `width`/`height`) is provided, the thumbnail is automatically
}
```
## Signals
On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)).
## Response `200`
Updated label template

View File

@@ -20,6 +20,10 @@ Creates a new conversation in state `idle`.
}
```
## Signals
On success, emits SSE signal: `modelconversation` (see [`GET /events`](../events.md)).
## Response `201`
Created conversation

View File

@@ -12,6 +12,10 @@ Deletes one of the authenticated user's model conversations and all its messages
|-------|----------|------|-------------|
| `id` | yes | integer | Conversation ID |
## Signals
On success, emits SSE signal: `modelconversation` (see [`GET /events`](../events.md)).
## Response `204`
Conversation deleted

View File

@@ -34,6 +34,10 @@ Refused with 409 unless the conversation state is `idle`.
}
```
## Signals
On success, emits SSE signal: `modelconversation-<id>` (see [`GET /events`](../events.md)).
## Response `201`
Created message

View File

@@ -34,6 +34,10 @@ Updates specified fields of an existing printer for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `200`
Updated printer

View File

@@ -30,6 +30,10 @@ Creates a new printer for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `201`
Created printer

View File

@@ -12,6 +12,10 @@ Deletes a printer for the authenticated user's selected company.
|-------|----------|------|-------------|
| `id` | yes | integer | Printer ID |
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `204`
Printer deleted

View File

@@ -22,6 +22,10 @@ Reorders printers for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `200`
Applied printer order

View File

@@ -34,6 +34,10 @@ Updates specified fields of an existing printer for the selected company.
}
```
## Signals
On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)).
## Response `200`
Updated printer

View File

@@ -22,6 +22,10 @@ Creates a new property name for the selected company.
}
```
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `201`
Created property name

View File

@@ -12,6 +12,10 @@ Deletes a property name and cascades to article properties.
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `200`
Deleted successfully

View File

@@ -28,6 +28,10 @@ Updates name and/or sort for a property name.
}
```
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `200`
Updated property name

View File

@@ -20,6 +20,10 @@ Updates the display sort order of property names for the selected company.
}
```
## Signals
On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)).
## Response `200`
Reordered successfully

View File

@@ -41,6 +41,10 @@ of type "adjustment", allocating a number from the "adjustment" number scheme.
}
```
## Signals
On success, emits SSE signals: `stock`, `ledger`, `articles`, `article-<sku>` (see [`GET /events`](../events.md)).
## Response `201`
Stock adjusted successfully

View File

@@ -26,6 +26,10 @@ Updates specified fields of an existing warehouse for the selected company.
}
```
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `200`
Updated warehouse

View File

@@ -22,6 +22,10 @@ Sort order will be automatically appended if omitted.
}
```
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `201`
Created warehouse

View File

@@ -12,6 +12,10 @@ Deletes a warehouse if not in use in the ledger.
|-------|----------|------|-------------|
| `id` | yes | integer | Warehouse ID |
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `204`
Warehouse deleted

View File

@@ -22,6 +22,10 @@ Reorders warehouses for the selected company.
}
```
## Signals
On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)).
## Response `200`
Applied warehouse order

View File

@@ -361,7 +361,7 @@ function renderOperation(apiPath, method, operation) {
}
const sseEvents = operation['x-sse-events'];
if (Array.isArray(sseEvents) && sseEvents.length) {
if (apiPath !== '/events' && Array.isArray(sseEvents) && sseEvents.length) {
lines.push('## SSE Events');
lines.push('');
lines.push('The response is a `text/event-stream`. Events are emitted in order until the stream closes.');
@@ -773,12 +773,23 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
for (const op of operations) {
const params = summarizeOperationParams(op.operation, aliases);
const returns = summarizeOperationReturns(op.operation, aliases);
const hasSseResponse = Object.values(op.operation.responses || {}).some((response) =>
Object.hasOwn(response.content || {}, 'text/event-stream'),
);
const method = op.method.toUpperCase();
const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim();
const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``;
lines.push(`#### ${heading}`);
lines.push(params === '—' ? '_none_' : `- Params: \`${params}\``);
lines.push(returns === '—' ? '_no response body_' : `- Returns: \`${returns}\``);
const sseEvents = op.operation['x-sse-events'];
if (hasSseResponse && Array.isArray(sseEvents) && sseEvents.length) {
lines.push('');
lines.push('- Events:');
for (const event of sseEvents) {
lines.push(` - \`${event.name}\`: \`${event.payload || '{}'}\``);
}
}
}
lines.push('');
return lines.join('\n');

View File

@@ -369,6 +369,47 @@
* description: Brand pictures sorted by sort
* items:
* $ref: '#/components/schemas/BrandPic'
* BrandListPic:
* type: object
* required: [fullHash, thumbW, thumbH, thumbHash]
* properties:
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* smallThumbW:
* type: integer
* nullable: true
* description: Width in pixels of the small AVIF thumbnail
* smallThumbH:
* type: integer
* nullable: true
* description: Height in pixels of the small AVIF thumbnail
* smallThumbBinary:
* type: string
* format: byte
* nullable: true
* description: Base64-encoded small AVIF thumbnail bytes
* BrandList:
* type: object
* required: [id, name, pics]
* properties:
* id:
* type: integer
* name:
* type: string
* gsprpinfo:
* type: string
* nullable: true
* pics:
* type: array
* description: Brand pictures sorted by sort, including the 40px-edge small thumbnail
* items:
* $ref: '#/components/schemas/BrandListPic'
* ArticleBrand:
* type: object
* required: [name]
@@ -1619,4 +1660,3 @@
*/
export {};

View File

@@ -55,6 +55,7 @@ export function register(app, basePath = '') {
* description: Creates an attribute name for the selected company.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* requestBody:
* required: true
* content:
@@ -118,6 +119,7 @@ export function register(app, basePath = '') {
* description: Updates the display sort order of attribute names for the selected company.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* requestBody:
* required: true
* content:
@@ -162,6 +164,7 @@ export function register(app, basePath = '') {
* description: Updates name, sort, and/or exclusive flag for an attribute name.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* parameters:
* - in: path
* name: id
@@ -197,6 +200,7 @@ export function register(app, basePath = '') {
* description: Deletes an attribute name and cascades to attribute values and article assignments.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* parameters:
* - in: path
* name: id

View File

@@ -47,7 +47,7 @@ export function register(app, basePath = '') {
* get:
* tags: [Brand]
* summary: List brands
* description: Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count.
* description: Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 40px maximum edge).
* security:
* - cookieAuth: []
* parameters:
@@ -66,6 +66,12 @@ export function register(app, basePath = '') {
* minimum: 1
* maximum: 100
* description: Number of brands to return
* - in: query
* name: includeSmallThumbs
* schema:
* type: boolean
* default: false
* description: Include each picture's small thumbnail dimensions and base64 AVIF bytes.
* x-markdown: brand/list.md
* responses:
* 200:
@@ -79,7 +85,7 @@ export function register(app, basePath = '') {
* brands:
* type: array
* items:
* $ref: '#/components/schemas/Brand'
* $ref: '#/components/schemas/BrandList'
* total:
* type: integer
* description: Total number of unpaged brands
@@ -90,7 +96,7 @@ export function register(app, basePath = '') {
* type: integer
* description: Number of brands returned
* 400:
* description: Invalid offset, invalid limit, or no company selected
* description: Invalid offset, invalid limit, invalid includeSmallThumbs, or no company selected
* content:
* application/json:
* schema:
@@ -122,6 +128,15 @@ export function register(app, basePath = '') {
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
const rawIncludeSmallThumbs = req.query.includeSmallThumbs;
if (
rawIncludeSmallThumbs !== undefined
&& rawIncludeSmallThumbs !== 'true'
&& rawIncludeSmallThumbs !== 'false'
) {
return res.status(400).json({ error: 'includeSmallThumbs must be true or false' });
}
let offset = 0;
let limit = 10;
@@ -141,7 +156,11 @@ export function register(app, basePath = '') {
}
try {
const result = await db.list(req.user.id, { offset, limit });
const result = await db.list(req.user.id, {
offset,
limit,
includeSmallThumbs: rawIncludeSmallThumbs === 'true',
});
res.status(200).json(result);
} catch (error) {
const status = brandErrorStatus(error);

View File

@@ -33,24 +33,68 @@ export function register(app, basePath = '') {
*
* ## Signals
*
* | Signal | Emitted after successful |
* |--------|--------------------------|
* | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |
* | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |
* | `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |
* | `brand` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
* Every SSE event uses the event name as the signal id; payloads are
* JSON and most events send an empty object `{}`. Only `brand` emits a
* richer payload.
*
* The `brand` signal carries a JSON payload `{ sender, brandId }`:
* `sender` echoes the caller's `x-client-id` header (empty string if
* absent) so the sender can ignore its own event; `brandId` is the
* affected brand id (`null` only if unknown). Other signals send `{}`.
* | Signal | Payload | Emitted after successful |
* |--------|---------|--------------------------|
* | `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` |
* | `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations |
* | `article-<sku>` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article |
* | `stock` | `{}` | `POST /stock/adjust` |
* | `ledger` | `{}` | `POST /stock/adjust` |
* | `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` |
* | `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` |
* | `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` |
* | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
* | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
* | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
* | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
* | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
* | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
*
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the
* caller's `x-client-id` header (empty string if absent) so the sender
* can ignore its own event; `brandId` is the affected brand id (`null`
* only if unknown). All other signals send `{}`.
*
* Signals are only sent when the mutation actually succeeds (2xx).
* Failed or no-op validation errors do not emit. The `article-<sku>`
* event name uses the concrete SKU (e.g. `article-42000045`).
* and `modelconversation-<id>` event names use the concrete item id at
* runtime (for example `article-42000045` and `modelconversation-123`).
* security:
* - cookieAuth: []
* x-markdown: events.md
* x-sse-events:
* - name: category
* payload: "{}"
* - name: articles
* payload: "{}"
* - name: article-<sku>
* payload: "{}"
* - name: stock
* payload: "{}"
* - name: ledger
* payload: "{}"
* - name: attributename
* payload: "{}"
* - name: propertyname
* payload: "{}"
* - name: labeltemplate
* payload: "{}"
* - name: printer
* payload: "{}"
* - name: warehouse
* payload: "{}"
* - name: vfs
* payload: "{}"
* - name: modelconversation
* payload: "{}"
* - name: modelconversation-<id>
* payload: "{}"
* - name: brand
* payload: "{ sender, brandId }"
* responses:
* 200:
* description: |

View File

@@ -176,6 +176,7 @@ export function register(app, basePath = '') {
* and stored with the template.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/create.md
* requestBody:
* required: true
@@ -293,6 +294,7 @@ export function register(app, basePath = '') {
* When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/update.md
* parameters:
* - in: path
@@ -343,6 +345,7 @@ export function register(app, basePath = '') {
* When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/update.md
* parameters:
* - in: path
@@ -398,6 +401,7 @@ export function register(app, basePath = '') {
* description: Deletes a label template for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-signals: [labeltemplate]
* x-markdown: labeltemplate/delete.md
* parameters:
* - in: path

View File

@@ -104,6 +104,7 @@ export function register(app, basePath = '') {
* description: Creates a new conversation in state `idle`.
* security:
* - cookieAuth: []
* x-signals: [modelconversation]
* requestBody:
* required: true
* content:
@@ -248,6 +249,7 @@ export function register(app, basePath = '') {
* description: Deletes one of the authenticated user's model conversations and all its messages.
* security:
* - cookieAuth: []
* x-signals: [modelconversation]
* parameters:
* - in: path
* name: id
@@ -493,6 +495,7 @@ export function register(app, basePath = '') {
* Refused with 409 unless the conversation state is `idle`.
* security:
* - cookieAuth: []
* x-signals: [modelconversation-<id>]
* parameters:
* - in: path
* name: id

View File

@@ -103,6 +103,7 @@ export function register(app, basePath = '') {
* `printerIds` must include all existing printer IDs exactly once.
* security:
* - cookieAuth: []
* x-signals: [printer]
* x-markdown: printer/order.md
* requestBody:
* required: true
@@ -253,6 +254,7 @@ export function register(app, basePath = '') {
* `type` must be `"esc/pos"` (lines), `"zpl"` (pages), or `"ipp"`.
* security:
* - cookieAuth: []
* x-signals: [printer]
* x-markdown: printer/create.md
* requestBody:
* required: true
@@ -355,6 +357,7 @@ export function register(app, basePath = '') {
* description: Updates specified fields of an existing printer for the selected company.
* security:
* - cookieAuth: []
* x-signals: [printer]
* parameters:
* - in: path
* name: id
@@ -409,6 +412,7 @@ export function register(app, basePath = '') {
* description: Updates specified fields of an existing printer for the selected company.
* security:
* - cookieAuth: []
* x-signals: [printer]
* parameters:
* - in: path
* name: id
@@ -469,6 +473,7 @@ export function register(app, basePath = '') {
* description: Deletes a printer for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-signals: [printer]
* parameters:
* - in: path
* name: id

View File

@@ -61,6 +61,7 @@ export function register(app, basePath = '') {
* description: Creates a new property name for the selected company.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* requestBody:
* required: true
* content:
@@ -130,6 +131,7 @@ export function register(app, basePath = '') {
* description: Updates the display sort order of property names for the selected company.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* requestBody:
* required: true
* content:
@@ -176,6 +178,7 @@ export function register(app, basePath = '') {
* description: Updates name and/or sort for a property name.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* parameters:
* - in: path
* name: id
@@ -211,6 +214,7 @@ export function register(app, basePath = '') {
* description: Deletes a property name and cascades to article properties.
* security:
* - cookieAuth: []
* x-signals: [propertyname]
* parameters:
* - in: path
* name: id

View File

@@ -71,6 +71,7 @@ export function register(app, basePath = '') {
* **Note:** `sku` must be a string (e.g. `"25"`), not a number. For single-item adjustments, provide `sku` and `amount` at top-level. For batch adjustments, provide `lines: [{ sku, amount, ... }]`.
* security:
* - cookieAuth: []
* x-signals: [stock, ledger, articles, article-<sku>]
* x-markdown: stock/adjust.md
* requestBody:
* required: true

View File

@@ -104,6 +104,7 @@ export function register(app, basePath = '') {
* `warehouseIds` must include all existing warehouse IDs exactly once.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* x-markdown: warehouse/order.md
* requestBody:
* required: true
@@ -254,6 +255,7 @@ export function register(app, basePath = '') {
* Sort order will be automatically appended if omitted.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* x-markdown: warehouse/create.md
* requestBody:
* required: true
@@ -408,6 +410,7 @@ export function register(app, basePath = '') {
* description: Updates specified fields of an existing warehouse for the selected company.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* parameters:
* - in: path
* name: id
@@ -474,6 +477,7 @@ export function register(app, basePath = '') {
* description: Deletes a warehouse if not in use in the ledger.
* security:
* - cookieAuth: []
* x-signals: [warehouse]
* parameters:
* - in: path
* name: id

View File

@@ -10,7 +10,19 @@ import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { createVfs } from './vfs.js';
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
const {
code,
root,
vmTimeout,
bufferLimit,
network,
dashbackUrl,
dashbackToken,
vfsDir,
googleAuthId,
appletInput,
appletFile,
} = workerData;
function resolveDashbackUrl(inputUrl, baseApiUrl) {
const raw = String(inputUrl || '').trim();
@@ -582,7 +594,13 @@ try {
let value;
let failed = null;
try {
value = vm.run(code, 'javascript_execute.js');
value = vm.run(code, appletFile || 'javascript_execute.js');
if (appletFile) {
if (typeof value !== 'function') {
throw new TypeError('Applet must assign an exported function to module.exports.');
}
value = value(appletInput);
}
if (value && typeof value.then === 'function') {
const settleTimeout = Math.max(1000, vmTimeout - 500);
value = await Promise.race([

View File

@@ -1,10 +1,11 @@
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import fs from 'fs';
import path from 'path';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
import { createVfs, prepareVfs, syncVfsToDb } from './vfs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '../..');
@@ -20,7 +21,7 @@ const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
// so warning per message floods the log. We warn once per distinct key instead.
const WATCH_NOISE_WARNED = new Set();
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) {
return new Promise((resolve) => {
let settled = false;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
@@ -35,6 +36,8 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
dashbackToken: token,
vfsDir: vfsRoot,
googleAuthId,
appletInput,
appletFile,
},
});
@@ -104,6 +107,67 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
});
}
function readApplet(vfsRoot, appletvfsfile) {
if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) {
throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.');
}
const appletPath = appletvfsfile.trim();
if (path.isAbsolute(appletPath) || /^[a-zA-Z]:/.test(appletPath) || appletPath.startsWith('vfs:')) {
throw new ToolError('"appletvfsfile" must be a VFS-relative file path.');
}
if (!appletPath.toLowerCase().endsWith('.applet.js')) {
throw new ToolError('"appletvfsfile" must refer to a .applet.js file.');
}
const vfs = createVfs(vfsRoot);
let source;
try {
const diskPath = vfs.resolveReadPath(appletPath);
const realRoot = fs.realpathSync(vfsRoot);
const realPath = fs.realpathSync(diskPath);
const relativePath = path.relative(realRoot, realPath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new ToolError(`Applet "${appletPath}" resolves outside the VFS.`);
}
if (!fs.statSync(realPath).isFile()) {
throw new ToolError(`Applet "${appletPath}" is not a file.`);
}
source = fs.readFileSync(realPath, 'utf8');
} catch (err) {
if (err instanceof ToolError) throw err;
throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`);
}
const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 3);
const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/);
const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
if (!inputHeader || !outputHeader) {
throw new ToolError(
'Applet metadata must be on the first two lines: ' +
'`// @applet-input-schema {JSON Schema}` followed by ' +
'`// @applet-output-schema {JSON Schema}`.'
);
}
let inputSchema;
let outputSchema;
try {
inputSchema = JSON.parse(inputHeader[1]);
outputSchema = JSON.parse(outputHeader[1]);
} catch (err) {
throw new ToolError(`Applet metadata must contain valid JSON schemas: ${err.message}`);
}
if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema) ||
inputSchema.type !== 'object') {
throw new ToolError('Applet input schema must be a JSON Schema with type "object".');
}
if (!outputSchema || typeof outputSchema !== 'object' || Array.isArray(outputSchema)) {
throw new ToolError('Applet output schema must be a JSON Schema object.');
}
return source;
}
function formatSandboxError(code, report) {
const errName = report.errorName || 'Error';
const msg = report.error || 'Unknown error';
@@ -122,7 +186,10 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
return defineTool({
name: 'javascript_execute',
description:
'Execute JS in isolated worker sandbox. Assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' +
'Execute JS in an isolated worker sandbox using either inline `code` or a VFS applet. Provide exactly one of `code` and `appletvfsfile`. ' +
'Applets are the preferred method when the first execution may throw: save a `.applet.js` file in the VFS so it can be inspected and further edited with `patch`, then call it by VFS path. ' +
'Applet files must start with `// @applet-input-schema {JSON Schema}` and `// @applet-output-schema {JSON Schema}` on their first two lines. The input schema describes the GUI form passed as `applet_input`; the output schema describes the exported function result. Export a function that receives `applet_input`. ' +
'Inline code: assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' +
'REST API: `await fetch("/path")` (authenticated). ' +
'VFS: directly use global `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only; paths must be relative paths, root is not allowed). VFS can host a static website (HTML, CSS, JS, images, etc.). ' +
'Embed generated images/links in markdown: `![alt](relative/path)`. Internet links use `https://...` or `hostname.com`/`hostname.com/filename`; a pure relative file with no dotted hostname is always a VFS relative path. Use find_files/read_file/patch for text. ' +
@@ -131,7 +198,6 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
note: {
type: ['string', 'null'],
@@ -139,7 +205,16 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
},
code: {
type: 'string',
description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
description: 'Optional inline JavaScript code. Provide exactly one of this and appletvfsfile. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
},
appletvfsfile: {
type: 'string',
description: 'Optional VFS-relative path to a .applet.js file. Provide exactly one of this and code. The file must begin with the two applet JSON Schema metadata lines described in the tool description.',
},
applet_input: {
type: 'object',
description: 'Input values passed to the exported function when running appletvfsfile. Omit to pass an empty object.',
additionalProperties: true,
},
timeout: {
type: ['number', 'null'],
@@ -151,17 +226,48 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, note }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
invoke: async ({
code,
appletvfsfile,
applet_input,
timeout = DEFAULT_TIMEOUT_MS,
max_output = DEFAULT_MAX_OUTPUT,
note,
}) => {
const hasCode = typeof code === 'string' && Boolean(code.trim());
const hasApplet = typeof appletvfsfile === 'string' && Boolean(appletvfsfile.trim());
if (hasCode === hasApplet) {
throw new ToolError('Provide exactly one of "code" or "appletvfsfile".');
}
if (code != null && !hasCode) {
throw new ToolError('"code" must be a non-empty string when provided.');
}
if (appletvfsfile != null && !hasApplet) {
throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.');
}
if (hasCode && applet_input !== undefined) {
throw new ToolError('"applet_input" can only be provided with "appletvfsfile".');
}
if (applet_input !== undefined &&
(!applet_input || typeof applet_input !== 'object' || Array.isArray(applet_input))) {
throw new ToolError('"applet_input" must be an object.');
}
const hardTimeout = Math.min(Math.max(1000, Number(timeout) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
const maxOutput = Math.max(1000, Number(max_output) || DEFAULT_MAX_OUTPUT);
const vfsRoot = await prepareVfs({ googleAuthId });
const isApplet = hasApplet;
const source = isApplet ? readApplet(vfsRoot, appletvfsfile) : code;
let report;
try {
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
report = await runInWorker({
code: source,
timeoutMs: hardTimeout,
vfsRoot,
googleAuthId,
appletInput: isApplet ? (applet_input ?? {}) : undefined,
appletFile: isApplet ? appletvfsfile.trim() : undefined,
});
} finally {
await syncVfsToDb({ googleAuthId, vfsRoot });
}
@@ -173,7 +279,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
}
if (report.error) {
throw new ToolError(formatSandboxError(code, report));
throw new ToolError(formatSandboxError(source, report));
}
// The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence