Compare commits

...

12 Commits

Author SHA1 Message Date
seb
2de2bc0d90 u 2026-10-13 13:21:14 +02:00
seb
8acca998bc u 2026-10-13 01:30:09 +02:00
seb
97540fb745 u 2026-10-13 00:36:38 +02:00
seb
2f92199d7f u 2026-10-12 22:41:14 +02:00
seb
3d009ba216 u 2026-10-12 22:32:18 +02:00
seb
f743ace6af u 2026-10-12 19:48:04 +02:00
seb
cc77bf4377 u 2026-10-12 19:23:20 +02:00
seb
77367b7716 u 2026-10-12 18:36:55 +02:00
seb
3748020b03 u 2026-10-12 18:29:29 +02:00
seb
c35126bb11 u 2026-10-12 18:09:57 +02:00
seb
713c31a21e u 2026-10-12 18:01:21 +02:00
seb
358a023b10 u 2026-10-12 17:42:46 +02:00
67 changed files with 8020 additions and 1453 deletions

View File

@@ -19,6 +19,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da
cp .env.example .env && npm install
npm start # PORT 4208 (HTTPS 8443)
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes-all.md + docs/api/routes/ (no openapi.json on disk — /docs/ builds it live)
npm run api-graph # regenerates api-graph.svg + api-graph.dot
npm run manage-users # email allowlist
```

View File

@@ -27,9 +27,10 @@ Docs are generated from `@openapi` JSDoc on route handlers:
```bash
npm run docs:api
npm run api-graph # regenerate api-graph.svg + api-graph.dot
```
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`. There is no generated `openapi.json` on disk — the interactive Swagger UI at `/docs/` builds the spec from the same `@openapi` JSDoc at runtime (`src/api/openapiSpec.js`).
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`. `npm run api-graph` generates `api-graph.svg` and `api-graph.dot` visualizing the API resources, endpoints, and dataflow relationships. There is no generated `openapi.json` on disk — the interactive Swagger UI at `/docs/` builds the spec from the same `@openapi` JSDoc at runtime (`src/api/openapiSpec.js`).
### Function review files

173
api-graph.dot Normal file

File diff suppressed because one or more lines are too long

2509
api-graph.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 289 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| File | Endpoint |
|------|----------|
| [`routes/.well-known/oauth-authorization-server.md`](routes/.well-known/oauth-authorization-server.md) | `GET /.well-known/oauth-authorization-server` |
| [`routes/.well-known/oauth-protected-resource.md`](routes/.well-known/oauth-protected-resource.md) | `GET /.well-known/oauth-protected-resource` |
| [`routes/account.get.md`](routes/account.get.md) | `GET /account` |
| [`routes/account.post.md`](routes/account.post.md) | `POST /account` |
| [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` |
@@ -155,6 +157,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/ledger.post.md`](routes/ledger.post.md) | `POST /ledger` |
| [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` |
| [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` |
| [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` |
| [`routes/mcp/authorizations.md`](routes/mcp/authorizations.md) | `GET /mcp/authorizations` |
| [`routes/mcp/authorizations/_clientId.md`](routes/mcp/authorizations/_clientId.md) | `DELETE /mcp/authorizations/{clientId}` |
| [`routes/mcp/authorize.get.md`](routes/mcp/authorize.get.md) | `GET /mcp/authorize` |
| [`routes/mcp/authorize.post.md`](routes/mcp/authorize.post.md) | `POST /mcp/authorize` |
| [`routes/mcp/clients.md`](routes/mcp/clients.md) | `GET /mcp/clients` |
| [`routes/mcp/clients/_clientId.md`](routes/mcp/clients/_clientId.md) | `DELETE /mcp/clients/{clientId}` |
| [`routes/modelconversation.get.md`](routes/modelconversation.get.md) | `GET /modelconversation` |
| [`routes/modelconversation.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` |
| [`routes/modelconversation/_id.delete.md`](routes/modelconversation/_id.delete.md) | `DELETE /modelconversation/{id}` |
@@ -164,6 +173,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/modelconversation/_id/stream.md`](routes/modelconversation/_id/stream.md) | `GET /modelconversation/{id}/stream` |
| [`routes/number/list.md`](routes/number/list.md) | `GET /number` |
| [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` |
| [`routes/oauth/authorize.md`](routes/oauth/authorize.md) | `GET /oauth/authorize` |
| [`routes/oauth/introspect.md`](routes/oauth/introspect.md) | `POST /oauth/introspect` |
| [`routes/oauth/register.md`](routes/oauth/register.md) | `POST /oauth/register` |
| [`routes/oauth/token.md`](routes/oauth/token.md) | `POST /oauth/token` |
| [`routes/order/list.md`](routes/order/list.md) | `GET /order` |
| [`routes/order/create.md`](routes/order/create.md) | `POST /order` |
| [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` |
@@ -171,6 +184,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/order/update.md`](routes/order/update.md) | `PATCH /order/{id}` |
| [`routes/order/_id/payment.get.md`](routes/order/_id/payment.get.md) | `GET /order/{id}/payment` |
| [`routes/order/_id/payment.post.md`](routes/order/_id/payment.post.md) | `POST /order/{id}/payment` |
| [`routes/order/_id/shipment.get.md`](routes/order/_id/shipment.get.md) | `GET /order/{id}/shipment` |
| [`routes/order/_id/shipment.post.md`](routes/order/_id/shipment.post.md) | `POST /order/{id}/shipment` |
| [`routes/order/_id/shipping.get.md`](routes/order/_id/shipping.get.md) | `GET /order/{id}/shipping` |
| [`routes/order/_id/shipping.post.md`](routes/order/_id/shipping.post.md) | `POST /order/{id}/shipping` |
| [`routes/package/_id.md`](routes/package/_id.md) | `DELETE /package/{id}` |
@@ -206,6 +221,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
| [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` |
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
| [`routes/shipment/_id.delete.md`](routes/shipment/_id.delete.md) | `DELETE /shipment/{id}` |
| [`routes/shipment/_id.get.md`](routes/shipment/_id.get.md) | `GET /shipment/{id}` |
| [`routes/shipment/_id/shipping.get.md`](routes/shipment/_id/shipping.get.md) | `GET /shipment/{id}/shipping` |
| [`routes/shipment/_id/shipping.post.md`](routes/shipment/_id/shipping.post.md) | `POST /shipment/{id}/shipping` |
| [`routes/shipping/_id.delete.md`](routes/shipping/_id.delete.md) | `DELETE /shipping/{id}` |
| [`routes/shipping/_id.patch.md`](routes/shipping/_id.patch.md) | `PATCH /shipping/{id}` |
| [`routes/shipping/_id/package.get.md`](routes/shipping/_id/package.get.md) | `GET /shipping/{id}/package` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 210 API endpoints across all modules.
Overview of all 229 API endpoints across all modules.
### Notation
@@ -9,58 +9,69 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T32, brand: T29|null, categoryIds: int[], photos: T27[], eans: str[], prices: T40[], suppliers: T13[] }`
- `T3` = `{ order: T4 }`
- `T4` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str, shipping?: T39|null, shippings?: T39[], payment?: T35|null, payments?: T35[], paid?: bool, delivered?: bool }`
- `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str, shipping?: T33|null, shippings?: T33[], payment?: T39|null, payments?: T39[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
- `T3` = `{ article: T4 }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T36, brand: T34|null, categoryIds: int[], photos: T31[], eans: str[], prices: T42[], suppliers: T14[] }`
- `T5` = `{ delivery: T6 }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T22[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T44 }`
- `T7` = `{ labeltemplate: T8 }`
- `T8` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T18, thumb?: str|null }`
- `T9` = `{ quote: T12 }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T11` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T12` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str }`
- `T13` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T46|null }`
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T15` = `{ ledger: T16[] }`
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T33[], articleCount?: int }`
- `T18` = `{ objects: T23[] }`
- `T19` = `{ objects: T24[] }`
- `T20` = `{ printer: T25 }`
- `T21` = `{ entity: T28 }`
- `T22` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T24` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T25` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T26` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T27` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T28` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T29` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T30` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
- `T31` = `{ article: T37 }`
- `T32` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T33` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T34` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T35` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T36` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T37` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T38` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T39` = `{ id: int, manifestId: int, number: str, type: str, state: str }`
- `T40` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T41` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T42` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T43` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T44` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T45` = `{ warehouse: T47 }`
- `T46` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T47` = `{ id: int, name: str, sort: int }`
- `T48` = `{ id: int, categoryIds: int[] }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T25[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T46 }`
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T25[], shippings: T33[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T8` = `{ labeltemplate: T9 }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T21, thumb?: str|null }`
- `T10` = `{ quote: T13 }`
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T12` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T13` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str }`
- `T14` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }`
- `T15` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T16` = `{ ledger: T17[] }`
- `T17` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T18` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[], articleCount?: int }`
- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
- `T20` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T21` = `{ objects: T26[] }`
- `T22` = `{ objects: T27[] }`
- `T23` = `{ printer: T29 }`
- `T24` = `{ entity: T32 }`
- `T25` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T26` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T27` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T28` = `{ shipping: T33 }`
- `T29` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T30` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T31` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T32` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T33` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T34` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T35` = `{ article: T40 }`
- `T36` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T37` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T38` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T39` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T40` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T41` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T42` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T43` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T44` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T45` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T46` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T47` = `{ warehouse: T49 }`
- `T48` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T49` = `{ id: int, name: str, sort: int }`
- `T50` = `{ id: int, categoryIds: int[] }`
### Routes
### Section: /.well-known
#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server`
_none_
- Returns: `200`
#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource`
_none_
- Returns: `200`
### Section: /account
#### List accounts — `GET /account`
@@ -77,7 +88,7 @@ _none_
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T31`
- Returns: `201 T35`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -86,10 +97,10 @@ _none_
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: str`
- Returns: `200 T1`
- Returns: `200 T3`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
- Params: `sku: str`
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
@@ -104,25 +115,25 @@ _none_
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int|null }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: str, body: { categoryIds: int[] }`
- Returns: `200 T1`
- Returns: `200 T3`
#### List child articles of a parent article — `GET /article/{sku}/children`
- Params: `sku: str`
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T31`
- Returns: `201 T35`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T37, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T40, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
- Params: `sku: str, body: { eans: str[] }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Get article history by SKU — `GET /article/{sku}/history`
- Params: `sku: str, query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }`
@@ -143,10 +154,10 @@ _none_
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T31`
- Returns: `200 T35`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Returns: `200 { success: bool }`
@@ -161,10 +172,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T13[] }`
- Returns: `200 { suppliers: T14[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T13 }`
- Returns: `201 { supplier: T14 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -173,31 +184,31 @@ _none_
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T13 }`
- Returns: `200 { supplier: T14 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Set article weight by SKU — `PUT /article/{sku}/weight`
- Params: `sku: str, body: { weight: num }`
- Returns: `200 T1`
- Returns: `200 T3`
### Section: /attributename
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T17[] }`
- Returns: `200 { attributenames: T18[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T17 }`
- Returns: `201 { attributename: T18 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T33[] }`
- Returns: `200 { values: T37[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T33 }`
- Returns: `201 { attributevalue: T37 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -206,7 +217,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T17 }`
- Returns: `200 { attributename: T18 }`
### Section: /attributevalue
@@ -215,28 +226,28 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T33 }`
- Returns: `200 { attributevalue: T37 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T41[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T41[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T38 }`
- Returns: `200 { message: str, user: T41 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T38, googleClientId: str }`
- Returns: `200 { user: T41, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T38 }`
- Returns: `200 { user: T41 }`
### Section: /brand
@@ -245,7 +256,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, 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: T41[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T43[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -254,10 +265,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T41[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
#### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T41[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
### Section: /category
@@ -272,7 +283,7 @@ _none_
- Returns: `200 { categoryIds: int[] }`
#### Category tree — `GET /category/tree`
_none_
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }[], articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T29|null }[] }`
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, articleCount?: int }[], articleCount: int }`
#### Delete category — `DELETE /category/{id}`
- Params: `id: int, query: recursive?: bool`
- Returns: `204 void`
@@ -281,7 +292,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T29|null }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T34|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -308,10 +319,10 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T36[] }`
- Returns: `200 { companies: T20[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T36, user: T38 }`
- Returns: `201 { company: T20, user: T41 }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -335,7 +346,7 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T36 }`
- Returns: `200 { company: T20 }`
### Section: /customergroup
@@ -344,7 +355,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T47 }`
- Returns: `201 { customergroup: T49 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -353,15 +364,15 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T47 }`
- Returns: `200 { customergroup: T49 }`
### Section: /delivery
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T44 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T46 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T26[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Params: `body: { externalNumber: str, date: str, lines: T30[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T5`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
@@ -382,7 +393,7 @@ _none_
- Params: `id: int`
- Returns: `200 T5`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T26[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T30[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T5`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
@@ -392,46 +403,46 @@ _none_
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T28[] }`
- Returns: `200 { entities: T32[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T21`
- Returns: `201 T24`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T14[] }`
- Returns: `200 { addresses: T15[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T14 }`
- Returns: `201 { address: T15 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T14 }`
- Returns: `200 { address: T15 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T42[] }`
- Returns: `200 { contacts: T44[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T42 }`
- Returns: `201 { contact: T44 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T42 }`
- Returns: `200 { contact: T44 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T21`
- Returns: `200 T24`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T21`
- Returns: `200 T24`
### Section: /events
@@ -473,50 +484,74 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T19 }`
- Returns: `201 T7`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T22 }`
- Returns: `201 T8`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 T7`
- Returns: `200 T8`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
- Returns: `200 T7`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
- Returns: `200 T8`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
- Returns: `200 T7`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
- Returns: `200 T8`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T15`
- Returns: `200 T16`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T16 }`
- Returns: `201 { ledger: T17 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T15`
- Returns: `200 T16`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T15`
- Returns: `200 T16`
### Section: /mcp
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
_none_
- Returns: `200`
#### List active MCP client authorizations — `GET /mcp/authorizations`
_none_
- Returns: `200`
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
- Params: `clientId: str`
- Returns: `200`
#### Get MCP authorization prompt details — `GET /mcp/authorize`
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
- Returns: `200`
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
- Returns: `200`
#### List registered MCP clients — `GET /mcp/clients`
_none_
- Returns: `200`
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
- Params: `clientId: str`
- Returns: `200`
### Section: /modelconversation
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T30[] }`
- Returns: `200 { conversations: T19[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T30 }`
- Returns: `201 { conversation: T19 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T30 }`
- Returns: `200 { conversation: T19 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -536,35 +571,56 @@ _none_
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
### Section: /oauth
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
_no response body_
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
_none_
- Returns: `200`
#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register`
- Params: `body: { redirect_uris: str[], client_name?: str }`
- Returns: `201`
#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token`
- Params: `application/x-www-form-urlencoded`
- Returns: `200`
### Section: /order
#### List orders — `GET /order`
- Params: `query: offset?: int, limit?: int`
- Returns: `200 { orders: T4[], total: int, offset: int, limit: int }`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T26[], entityId?: int|null }`
- Returns: `201 T3`
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
- Returns: `201 T1`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get order — `GET /order/{id}`
- Params: `id: int`
- Returns: `200 T3`
- Returns: `200 T1`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T26[] }`
- Returns: `200 T3`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
- Returns: `200 T1`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T35[] }`
- Returns: `200 { payments: T39[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T35 }`
- Returns: `201 { payment: T39 }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
#### Create shipment for an order — `POST /order/{id}/shipment`
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
- Returns: `201 { shipment: T7 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T39[] }`
- Returns: `200 { shippings: T33[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 { shipping: T39 }`
- Returns: `201 T28`
### Section: /package
@@ -582,37 +638,37 @@ _none_
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: T11[] }`
- Returns: `200 { posterminals: T12[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
- Returns: `200 { posterminal: T11 }`
- Returns: `200 { posterminal: T12 }`
#### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update POS terminal — `PATCH /posterminal/{id}`
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
- Returns: `200 { posterminal: T11 }`
- Returns: `200 { posterminal: T12 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T48`
- Returns: `200 T50`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T48`
- Returns: `200 T50`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T48`
- Returns: `200 T50`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T48`
- Returns: `200 T50`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T25[] }`
- Returns: `200 { printers: T29[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T20`
- Returns: `201 T23`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -621,22 +677,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T20`
- Returns: `200 T23`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T20`
- Returns: `200 T23`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T20`
- Returns: `200 T23`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T34[] }`
- Returns: `200 { propertynames: T38[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T34 }`
- Returns: `201 { propertyname: T38 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -645,7 +701,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T34 }`
- Returns: `200 { propertyname: T38 }`
### Section: /public
@@ -668,17 +724,17 @@ _none_
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T26[], entityId?: int|null }`
- Returns: `201 T9`
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
- Returns: `201 T10`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T9`
- Returns: `200 T10`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T26[] }`
- Returns: `200 T9`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
- Returns: `200 T10`
### Section: /serper
@@ -695,14 +751,29 @@ _none_
- `done`: `{ "total": 100 }`
- `error`: `{ "error": "…" }`
### Section: /shipment
#### Delete shipment — `DELETE /shipment/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get shipment by ID — `GET /shipment/{id}`
- Params: `id: int`
- Returns: `200 { shipment: T7 }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T33[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T28`
### Section: /shipping
#### Delete shipping — `DELETE /shipping/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, warehouseId?: int }`
- Returns: `200 { shipping: T39 }`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T28`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -720,28 +791,28 @@ _none_
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T17[] }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T43[] }`
- Returns: `200 { suppliers: T45[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T43 }`
- Returns: `201 { supplier: T45 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T43 }`
- Returns: `200 { supplier: T45 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T46|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T46|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
### Section: /thumb
@@ -756,10 +827,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T32[] }`
- Returns: `200 { units: T36[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T32 }`
- Returns: `200 { unit: T36 }`
### Section: /v1
@@ -810,10 +881,10 @@ _none_
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T47[] }`
- Returns: `200 { warehouses: T49[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T45`
- Returns: `201 T47`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -822,10 +893,10 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T45`
- Returns: `200 T47`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T45`
- Returns: `200 T47`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T45`
- Returns: `200 T47`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 163 API endpoints across all modules.
Overview of all 182 API endpoints across all modules.
### Notation
@@ -9,53 +9,64 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T30, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T36[], suppliers: T12[] }`
- `T3` = `{ order: T4 }`
- `T4` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T21[], createdAt?: str, shipping?: T35|null, shippings?: T35[], payment?: T33|null, payments?: T33[], paid?: bool, delivered?: bool }`
- `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str, shipping?: T30|null, shippings?: T30[], payment?: T36|null, payments?: T36[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
- `T3` = `{ article: T4 }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T33, brand: T31|null, categoryIds: int[], photos: T28[], eans: str[], prices: T38[], suppliers: T13[] }`
- `T5` = `{ delivery: T6 }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T21[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
- `T7` = `{ labeltemplate: T8 }`
- `T8` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T17, thumb?: str|null }`
- `T9` = `{ quote: T11 }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T11` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T21[], createdAt?: str }`
- `T12` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }`
- `T13` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T14` = `{ ledger: T15[] }`
- `T15` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T16` = `{ id: int, name: str, sort: int, exclusive: bool, values: T31[], articleCount?: int }`
- `T17` = `{ objects: T22[] }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T22[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T42 }`
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T22[], shippings: T30[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T8` = `{ labeltemplate: T9 }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T18, thumb?: str|null }`
- `T10` = `{ quote: T12 }`
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T12` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str }`
- `T13` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }`
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T15` = `{ ledger: T16[] }`
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T34[], articleCount?: int }`
- `T18` = `{ objects: T23[] }`
- `T19` = `{ printer: T24 }`
- `T20` = `{ entity: T27 }`
- `T21` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T19` = `{ objects: T24[] }`
- `T20` = `{ printer: T26 }`
- `T21` = `{ entity: T29 }`
- `T22` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T24` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T25` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T26` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T29` = `{ article: T34 }`
- `T30` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T31` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T34` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T35` = `{ id: int, manifestId: int, number: str, type: str, state: str }`
- `T36` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T41` = `{ warehouse: T43 }`
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T43` = `{ id: int, name: str, sort: int }`
- `T24` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T25` = `{ shipping: T30 }`
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T27` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T28` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T29` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T30` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T31` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T32` = `{ article: T37 }`
- `T33` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T34` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T35` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T36` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T37` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T38` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T39` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T40` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T41` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T42` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T43` = `{ warehouse: T45 }`
- `T44` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T45` = `{ id: int, name: str, sort: int }`
### Routes
### Section: /.well-known
#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server`
_none_
- Returns: `200`
#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource`
_none_
- Returns: `200`
### Section: /account
#### List accounts — `GET /account`
@@ -72,16 +83,16 @@ _none_
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T29`
- Returns: `201 T32`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: str`
- Returns: `200 T1`
- Returns: `200 T3`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
- Params: `sku: str`
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
@@ -96,25 +107,25 @@ _none_
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
- Params: `sku: str, body: { brandId: int|null }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: str, body: { categoryIds: int[] }`
- Returns: `200 T1`
- Returns: `200 T3`
#### List child articles of a parent article — `GET /article/{sku}/children`
- Params: `sku: str`
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T29`
- Returns: `201 T32`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T34, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T37, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
- Params: `sku: str, body: { eans: str[] }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Get article history by SKU — `GET /article/{sku}/history`
- Params: `sku: str, query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }`
@@ -132,10 +143,10 @@ _none_
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T29`
- Returns: `200 T32`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Returns: `200 { success: bool }`
@@ -150,10 +161,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T12[] }`
- Returns: `200 { suppliers: T13[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T12 }`
- Returns: `201 { supplier: T13 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -162,31 +173,31 @@ _none_
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T12 }`
- Returns: `200 { supplier: T13 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T1`
- Returns: `200 T3`
#### Set article weight by SKU — `PUT /article/{sku}/weight`
- Params: `sku: str, body: { weight: num }`
- Returns: `200 T1`
- Returns: `200 T3`
### Section: /attributename
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T16[] }`
- Returns: `200 { attributenames: T17[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T16 }`
- Returns: `201 { attributename: T17 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T31[] }`
- Returns: `200 { values: T34[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T31 }`
- Returns: `201 { attributevalue: T34 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -195,7 +206,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T16 }`
- Returns: `200 { attributename: T17 }`
### Section: /attributevalue
@@ -204,13 +215,13 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T31 }`
- Returns: `200 { attributevalue: T34 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T39[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T39[] } }`
### Section: /auth
@@ -228,7 +239,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, 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: T37[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T39[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -237,10 +248,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T39[] } }`
#### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T39[] } }`
### Section: /category
@@ -252,7 +263,7 @@ _none_
- Returns: `200 { categoryIds: int[] }`
#### Category tree — `GET /category/tree`
_none_
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }[], articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T28|null }[] }`
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, articleCount?: int }[], articleCount: int }`
#### Delete category — `DELETE /category/{id}`
- Params: `id: int, query: recursive?: bool`
- Returns: `204 void`
@@ -261,7 +272,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T28|null }[] }`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T31|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -285,7 +296,7 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }[] }`
- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }[] }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -306,7 +317,7 @@ _none_
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num } }`
- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num } }`
### Section: /customergroup
@@ -315,7 +326,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T43 }`
- Returns: `201 { customergroup: T45 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -324,15 +335,15 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T43 }`
- Returns: `200 { customergroup: T45 }`
### Section: /delivery
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T42 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T25[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Params: `body: { externalNumber: str, date: str, lines: T27[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T5`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int`
@@ -341,7 +352,7 @@ _none_
- Params: `id: int`
- Returns: `200 T5`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T25[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T27[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T5`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
@@ -351,46 +362,46 @@ _none_
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T27[] }`
- Returns: `200 { entities: T29[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T20`
- Returns: `201 T21`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T13[] }`
- Returns: `200 { addresses: T14[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T13 }`
- Returns: `201 { address: T14 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T13 }`
- Returns: `200 { address: T14 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T38[] }`
- Returns: `200 { contacts: T40[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T38 }`
- Returns: `201 { contact: T40 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T38 }`
- Returns: `200 { contact: T40 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T20`
- Returns: `200 T21`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T20`
- Returns: `200 T21`
### Section: /labeltemplate
@@ -398,35 +409,59 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T18 }`
- Returns: `201 T7`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T19 }`
- Returns: `201 T8`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 T7`
- Returns: `200 T8`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T18 }`
- Returns: `200 T7`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
- Returns: `200 T8`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T18 }`
- Returns: `200 T7`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
- Returns: `200 T8`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T14`
- Returns: `200 T15`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T15 }`
- Returns: `201 { ledger: T16 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T14`
- Returns: `200 T15`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T14`
- Returns: `200 T15`
### Section: /mcp
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
_none_
- Returns: `200`
#### List active MCP client authorizations — `GET /mcp/authorizations`
_none_
- Returns: `200`
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
- Params: `clientId: str`
- Returns: `200`
#### Get MCP authorization prompt details — `GET /mcp/authorize`
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
- Returns: `200`
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
- Returns: `200`
#### List registered MCP clients — `GET /mcp/clients`
_none_
- Returns: `200`
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
- Params: `clientId: str`
- Returns: `200`
### Section: /number
@@ -437,35 +472,56 @@ _none_
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
### Section: /oauth
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
_no response body_
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
_none_
- Returns: `200`
#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register`
- Params: `body: { redirect_uris: str[], client_name?: str }`
- Returns: `201`
#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token`
- Params: `application/x-www-form-urlencoded`
- Returns: `200`
### Section: /order
#### List orders — `GET /order`
- Params: `query: offset?: int, limit?: int`
- Returns: `200 { orders: T4[], total: int, offset: int, limit: int }`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }`
- Returns: `201 T3`
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
- Returns: `201 T1`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get order — `GET /order/{id}`
- Params: `id: int`
- Returns: `200 T3`
- Returns: `200 T1`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }`
- Returns: `200 T3`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T27[] }`
- Returns: `200 T1`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T33[] }`
- Returns: `200 { payments: T36[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T33 }`
- Returns: `201 { payment: T36 }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
#### Create shipment for an order — `POST /order/{id}/shipment`
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
- Returns: `201 { shipment: T7 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T35[] }`
- Returns: `200 { shippings: T30[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 { shipping: T35 }`
- Returns: `201 T25`
### Section: /package
@@ -483,10 +539,10 @@ _none_
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T24[] }`
- Returns: `200 { printers: T26[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T19`
- Returns: `201 T20`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -495,22 +551,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T19`
- Returns: `200 T20`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T19`
- Returns: `200 T20`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T19`
- Returns: `200 T20`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T32[] }`
- Returns: `200 { propertynames: T35[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T32 }`
- Returns: `201 { propertyname: T35 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -519,7 +575,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T32 }`
- Returns: `200 { propertyname: T35 }`
### Section: /quote
@@ -527,17 +583,32 @@ _none_
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }`
- Returns: `201 T9`
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
- Returns: `201 T10`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T9`
- Returns: `200 T10`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }`
- Returns: `200 T9`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T27[] }`
- Returns: `200 T10`
### Section: /shipment
#### Delete shipment — `DELETE /shipment/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get shipment by ID — `GET /shipment/{id}`
- Params: `id: int`
- Returns: `200 { shipment: T7 }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T30[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T25`
### Section: /shipping
@@ -545,8 +616,8 @@ _none_
- Params: `id: int`
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, warehouseId?: int }`
- Returns: `200 { shipping: T35 }`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T25`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -564,28 +635,28 @@ _none_
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T15[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T39[] }`
- Returns: `200 { suppliers: T41[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T39 }`
- Returns: `201 { supplier: T41 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T39 }`
- Returns: `200 { supplier: T41 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }[] }`
### Section: /unit
@@ -594,19 +665,19 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T30[] }`
- Returns: `200 { units: T33[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T30 }`
- Returns: `200 { unit: T33 }`
### Section: /warehouse
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T43[] }`
- Returns: `200 { warehouses: T45[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T41`
- Returns: `201 T43`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -615,13 +686,13 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T41`
- Returns: `200 T43`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T41`
- Returns: `200 T43`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T41`
- Returns: `200 T43`
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:

View File

@@ -0,0 +1,15 @@
# `GET /.well-known/oauth-authorization-server`
Public. No cookie required.
OAuth 2.0 Authorization Server Metadata (RFC 8414)
Returns OAuth 2.0 authorization server endpoints and capabilities.
## Response `200`
Authorization Server Metadata
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,15 @@
# `GET /.well-known/oauth-protected-resource`
Public. No cookie required.
OAuth 2.0 Protected Resource Metadata (RFC 9728)
Returns protected resource metadata discovering the authorization server for the MCP endpoint.
## Response `200`
Protected Resource Metadata
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -8,7 +8,7 @@ Returns the category tree for the authenticated user's selected company.
## Response `200`
Tree and articles for the selected company
Tree and article count for the selected company
```json
{
@@ -24,39 +24,13 @@ Tree and articles for the selected company
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"articleCount": 1,
"posterminalIds": [
1
]
}
],
"articles": [
{
"id": 1,
"sku": "…",
"name": "…",
"slug": "…",
"net": 1,
"taxRate": 1,
"track_stock": true,
"optionsCount": 1,
"articleListCount": 1,
"childCount": 1,
"parentArticleId": 1,
"parentSku": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"brand": {
"id": 1,
"name": "…",
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
}
]
"articleCount": 1
}
```

View File

@@ -32,10 +32,13 @@ Created; selected company updated
{
"company": {
"id": 1,
"iso639": "…",
"name": "…",
"rootCategoryId": 1,
"languageId": 1,
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1
"reducedtaxrate": 1,
"taxfreerate": 1
},
"user": {
"id": 1,

View File

@@ -15,10 +15,13 @@ Company list
"companies": [
{
"id": 1,
"iso639": "…",
"name": "…",
"rootCategoryId": 1,
"languageId": 1,
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1
"reducedtaxrate": 1,
"taxfreerate": 1
}
]
}

View File

@@ -38,10 +38,13 @@ Updated
{
"company": {
"id": 1,
"iso639": "…",
"name": "…",
"rootCategoryId": 1,
"languageId": 1,
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1
"reducedtaxrate": 1,
"taxfreerate": 1
}
}
```

21
docs/api/routes/mcp.md Normal file
View File

@@ -0,0 +1,21 @@
# `POST /mcp`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Model Context Protocol (MCP) Streamable HTTP endpoint
Handles MCP JSON-RPC protocol requests and streamable sessions. Protected by Bearer token authorization.
## Response `200`
MCP JSON-RPC response
## Errors
| Status | Description |
|--------|-------------|
| 401 | Missing or invalid authorization token |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,21 @@
# `GET /mcp/authorizations`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List active MCP client authorizations
Lists all MCP clients authorized by the authenticated user with token counts and authorization timestamps.
## Response `200`
List of active user authorizations
## Errors
| Status | Description |
|--------|-------------|
| 401 | User not authenticated |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,28 @@
# `DELETE /mcp/authorizations/{clientId}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Revoke MCP client authorizations
Revokes all tokens and authorizations granted to an MCP client by the authenticated user.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `clientId` | yes | string | |
## Response `200`
Authorizations revoked
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing clientId |
| 401 | User not authenticated |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,35 @@
# `GET /mcp/authorize`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get MCP authorization prompt details
Fetches client information and current user context for the frontend MCP authorization consent screen.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `client_id` | yes | string | |
| `redirect_uri` | yes | string | |
| `response_type` | no | string | |
| `code_challenge` | no | string | |
| `code_challenge_method` | no | string | |
| `state` | no | string | |
| `scope` | no | string | |
## Response `200`
Client and user consent metadata
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid request or unregistered redirect_uri |
| 401 | User not authenticated |
| 404 | Client not found |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,47 @@
# `POST /mcp/authorize`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Grant or deny MCP client authorization
Issues an OAuth authorization code or refusal for the requesting MCP client on behalf of the authenticated user.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `client_id` | yes | string | |
| `redirect_uri` | yes | string | |
| `code_challenge` | no | string | |
| `code_challenge_method` | no | string | |
| `state` | no | string | |
| `scope` | no | string | |
| `action` | yes | string | |
```json
{
"client_id": "…",
"redirect_uri": "…",
"code_challenge": "…",
"code_challenge_method": "…",
"state": "…",
"scope": "…",
"action": "allow"
}
```
## Response `200`
Success with redirect URL containing code or error
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid request parameters |
| 401 | User not authenticated |
| 404 | Client not found |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,21 @@
# `GET /mcp/clients`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List registered MCP clients
Lists all registered OAuth MCP clients.
## Response `200`
List of registered clients
## Errors
| Status | Description |
|--------|-------------|
| 401 | User not authenticated |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,28 @@
# `DELETE /mcp/clients/{clientId}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete registered MCP client
Deletes an MCP client registration.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `clientId` | yes | string | |
## Response `200`
Client deleted
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing clientId |
| 401 | User not authenticated |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -20,7 +20,9 @@ List of conversations
"model": "…",
"created_at": "…",
"updated_at": "…",
"state": "idle"
"state": "idle",
"started_by": "…",
"first_message": "…"
}
]
}

View File

@@ -37,7 +37,9 @@ Created conversation
"model": "…",
"created_at": "…",
"updated_at": "…",
"state": "idle"
"state": "idle",
"started_by": "…",
"first_message": "…"
}
}
```

View File

@@ -25,7 +25,9 @@ Conversation details
"model": "…",
"created_at": "…",
"updated_at": "…",
"state": "idle"
"state": "idle",
"started_by": "…",
"first_message": "…"
}
}
```

View File

@@ -0,0 +1,28 @@
# `GET /oauth/authorize`
Public. No cookie required.
OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
Redirects browser to frontend authorization consent page.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `client_id` | no | string | |
| `redirect_uri` | no | string | |
| `response_type` | no | string | |
| `code_challenge` | no | string | |
| `code_challenge_method` | no | string | |
| `state` | no | string | |
## Errors
| Status | Description |
|--------|-------------|
| 302 | Redirects to frontend /oauth/authorize |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,15 @@
# `POST /oauth/introspect`
Public. No cookie required.
OAuth 2.0 Token Introspection (RFC 7662)
Verifies the validity of an issued access token.
## Response `200`
Token status and metadata
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,31 @@
# `POST /oauth/register`
Public. No cookie required.
Dynamic Client Registration (RFC 7591)
Registers a new OAuth MCP client dynamically.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `client_name` | no | string | |
| `redirect_uris` | yes | string[] | |
```json
{
"client_name": "…",
"redirect_uris": [
"…"
]
}
```
## Response `201`
Registered client credentials
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,30 @@
# `POST /oauth/token`
Public. No cookie required.
OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636)
Exchanges authorization code with PKCE for an access token, or refreshes an expired token.
## Body
`application/x-www-form-urlencoded`
```json
{
"grant_type": "authorization_code",
"code": "…",
"redirect_uri": "…",
"client_id": "…",
"code_verifier": "…",
"refresh_token": "…"
}
```
## Response `200`
Issued access token and refresh token
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,71 @@
# `GET /order/{id}/shipment`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List shipments of an order
Lists all shipment manifests belonging to the specified order.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Order ID |
## Response `200`
List of shipments
```json
{
"shipments": [
{
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Order not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,108 @@
# `POST /order/{id}/shipment`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create shipment for an order
Creates a new partial or full shipment manifest for the order.
Validates that article positions belong to the order and cumulative shipped quantities
do not exceed ordered quantities.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Order ID |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `date` | no | string | |
| `externalNumber` | no | string | |
| `lines` | yes | object[] | |
| `lines[].sku` | yes | string | |
| `lines[].amount` | yes | number | |
| `lines[].name` | no | string | |
| `lines[].pu` | no | number | |
| `lines[].net` | no | number | |
| `lines[].rebate` | no | number | |
| `lines[].taxrate` | no | number | |
| `lines[].comment` | no | string | |
```json
{
"date": "2026-08-10",
"externalNumber": "…",
"lines": [
{
"sku": "…",
"amount": 1,
"name": "…",
"pu": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
]
}
```
## Response `201`
Created shipment
```json
{
"shipment": {
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error or article not in order — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Order not found — `{"error":"…"}` |
| 409 | Cumulative shipment amount exceeds ordered amount — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -23,6 +23,8 @@ List of shippings
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}

View File

@@ -41,6 +41,8 @@ Created shipping
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}

View File

@@ -67,6 +67,8 @@ Created order
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
},
@@ -75,6 +77,8 @@ Created order
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
@@ -96,7 +100,49 @@ Created order
}
],
"paid": true,
"delivered": true
"shipped": true,
"delivered": true,
"orderTotal": 1,
"paidTotal": 1,
"orderedQuantity": 1,
"shippedQuantity": 1,
"shipments": [
{
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
]
}
}
```

View File

@@ -42,6 +42,8 @@ Order
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
},
@@ -50,6 +52,8 @@ Order
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
@@ -71,7 +75,49 @@ Order
}
],
"paid": true,
"delivered": true
"shipped": true,
"delivered": true,
"orderTotal": 1,
"paidTotal": 1,
"orderedQuantity": 1,
"shippedQuantity": 1,
"shipments": [
{
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
]
}
}
```

View File

@@ -15,6 +15,10 @@ Results are paged, include line items, shipping, payment, and provide total unpa
|-------|----------|------|-------------|
| `offset` | no | integer | Number of orders to skip |
| `limit` | no | integer | Number of orders to return |
| `search` | no | string | Free-text search matching order number, external number, or customer name |
| `onlyUnpaid` | no | boolean | When true, returns only unpaid orders (including overpaid orders) |
| `onlyUndelivered` | no | boolean | When true, returns only undelivered orders |
| `seekMonths` | no | integer | Negative integer for cutoff months ago (e.g. -1 for orders older than 1 month) |
## Response `200`
@@ -47,6 +51,8 @@ Order list
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
},
@@ -55,6 +61,8 @@ Order list
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
@@ -76,7 +84,49 @@ Order list
}
],
"paid": true,
"delivered": true
"shipped": true,
"delivered": true,
"orderTotal": 1,
"paidTotal": 1,
"orderedQuantity": 1,
"shippedQuantity": 1,
"shipments": [
{
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
]
}
],
"total": 1,
@@ -89,7 +139,7 @@ Order list
| Status | Description |
|--------|-------------|
| 400 | Invalid offset, invalid limit, or no company selected — `{"error":"…"}` |
| 400 | Invalid query parameters, invalid offset, invalid limit, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |

View File

@@ -61,6 +61,8 @@ Updated order
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
},
@@ -69,6 +71,8 @@ Updated order
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
@@ -90,7 +94,49 @@ Updated order
}
],
"paid": true,
"delivered": true
"shipped": true,
"delivered": true,
"orderTotal": 1,
"paidTotal": 1,
"orderedQuantity": 1,
"shippedQuantity": 1,
"shipments": [
{
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
]
}
}
```

View File

@@ -0,0 +1,32 @@
# `DELETE /shipment/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete shipment
Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Shipment ID |
## Response `204`
Shipment deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Shipment not found — `{"error":"…"}` |
| 409 | Shipped shipment cannot be deleted — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,69 @@
# `GET /shipment/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get shipment by ID
Retrieves shipment header, article positions, and attached packages.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Shipment ID |
## Response `200`
Shipment details
```json
{
"shipment": {
"id": 1,
"number": "…",
"type": "shipment",
"entityId": 1,
"externalNumber": "…",
"date": "…",
"createdAt": "…",
"lineCount": 1,
"packageCount": 1,
"lines": [
{
"id": 1,
"sku": "…",
"name": "…",
"pu": 1,
"amount": 1,
"net": 1,
"rebate": 1,
"taxrate": 1,
"comment": "…"
}
],
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Shipment not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,45 @@
# `GET /shipment/{id}/shipping`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List packages of a shipment
Lists all packages (shippings) belonging to the specified shipment manifest.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Shipment ID |
## Response `200`
List of packages
```json
{
"shippings": [
{
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Shipment not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,66 @@
# `POST /shipment/{id}/shipping`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create package for a shipment
Creates a physical package (shipping) for the shipment manifest.
Accepts carrier and parcel tracking code. If state is `shipped`,
deducts stock for the shipment's lines via ledger movements.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Shipment ID |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `type` | yes | string | |
| `state` | yes | string | |
| `trackingCode` | no | string | Carrier parcel tracking code |
| `carrier` | no | string | Carrier name (e.g. DHL, UPS, DPD) |
| `warehouseId` | no | integer | Optional warehouse ID for stock deduction when state is shipped |
```json
{
"type": "…",
"state": "…",
"trackingCode": "…",
"carrier": "…",
"warehouseId": 1
}
```
## Response `201`
Created package
```json
{
"shipping": {
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error, manifest is not a shipment, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Shipment or warehouse not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -20,12 +20,16 @@ via ledger and article history is written. Once shipped, it cannot be undone.
|-------|----------|------|-------------|
| `type` | no | string | |
| `state` | no | string | |
| `trackingCode` | no | string | Carrier parcel tracking code |
| `carrier` | no | string | Carrier name (e.g. DHL, UPS, DPD) |
| `warehouseId` | no | integer | Warehouse ID for stock deduction when transitioning to shipped |
```json
{
"type": "…",
"state": "…",
"trackingCode": "…",
"carrier": "…",
"warehouseId": 1
}
```
@@ -40,6 +44,8 @@ Updated shipping
"id": 1,
"manifestId": 1,
"number": "…",
"trackingCode": "…",
"carrier": "…",
"type": "…",
"state": "…"
}

781
generate-api-graph.js Normal file
View File

@@ -0,0 +1,781 @@
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Graphviz } from '@hpcc-js/wasm-graphviz';
import { getOpenApiSpec } from './src/api/openapiSpec.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outputPath = join(__dirname, 'api-graph.svg');
const dotOutputPath = join(__dirname, 'api-graph.dot');
const COLORS = {
background: '#1e2430',
tableBorder: '#3f4c5f',
headerBg: '#33415c',
headerText: '#e2e8f0',
rowBg: '#2a3242',
rowBgAlt: '#272e3d',
keyBg: '#2b3a52',
text: '#d8dee9',
muted: '#8b95a7',
clusterFill: '#202a3a',
};
const METHOD_COLORS = {
GET: { fg: '#38bdf8', bg: '#182b40' },
POST: { fg: '#4ade80', bg: '#143826' },
PUT: { fg: '#fbbf24', bg: '#3b2c12' },
PATCH: { fg: '#c084fc', bg: '#301c44' },
DELETE: { fg: '#f87171', bg: '#3a181e' },
};
const EDGE_COLORS = [
'#ff6b6b',
'#6bdf9a',
'#5aa7ff',
'#f5c15c',
'#c79bff',
'#ff9f5a',
'#5cd6d6',
'#ff7fc2',
'#8f95ff',
'#58d18a',
'#ff8f66',
'#4fc4e8',
'#a98cff',
'#ff6f6f',
'#4ecb8f',
'#5aa7e6',
'#ff7aa8',
'#e0b25a',
'#56c8c8',
'#ff5f9e',
'#ffb25a',
'#56d68a',
'#66b3ff',
'#c79bff',
];
const CLUSTERS = [
{
id: 'identity',
label: 'Identity & Access',
resources: ['Auth', 'Company', 'CompanySetting', 'Customergroup', 'Number'],
},
{
id: 'crm',
label: 'Entities & CRM',
resources: ['Entity'],
},
{
id: 'catalog',
label: 'Catalog & Products',
resources: ['Article', 'Category', 'Brand', 'Unit', 'PublicCatalog'],
},
{
id: 'properties',
label: 'Properties & Attributes',
resources: ['Property', 'Attribute'],
},
{
id: 'purchasing',
label: 'Suppliers & Purchasing',
resources: ['Supplier', 'ArticleSupplier', 'Delivery'],
},
{
id: 'sales',
label: 'Sales & Fulfillment',
resources: ['Quote', 'Order', 'Shipment', 'Shipping', 'Package'],
},
{
id: 'inventory',
label: 'Inventory & Warehousing',
resources: ['Stock', 'Ledger', 'Warehouse'],
},
{
id: 'finance',
label: 'Finance & Payments',
resources: ['Account', 'Payment'],
},
{
id: 'media',
label: 'Media & Storage',
resources: ['Image', 'Serper', 'Vfs'],
},
{
id: 'pos',
label: 'POS & Hardware',
resources: ['POSTerminal', 'Printer', 'LabelTemplate', 'POSSync'],
},
{
id: 'ai_mcp',
label: 'AI & Integrations',
resources: ['ModelConversation', 'MCP'],
},
{
id: 'system',
label: 'System & Realtime',
resources: ['Events', 'Health', 'Telemetry', 'Legend'],
},
];
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Maps an OpenAPI endpoint to its logical API resource card.
*/
function assignResource(apiPath, method, tag) {
if (apiPath.startsWith('/auth') || apiPath.startsWith('/.well-known')) return 'Auth';
if (apiPath.startsWith('/company/setting') || apiPath.startsWith('/companysetting')) return 'CompanySetting';
if (apiPath.startsWith('/company')) return 'Company';
if (apiPath.startsWith('/customergroup')) return 'Customergroup';
if (apiPath.startsWith('/number')) return 'Number';
if (apiPath.startsWith('/entity')) return 'Entity';
if (apiPath.startsWith('/propertyname')) return 'Property';
if (apiPath.startsWith('/attributename') || apiPath.startsWith('/attributevalue')) return 'Attribute';
if (apiPath.startsWith('/article/') && apiPath.includes('/supplier')) return 'ArticleSupplier';
if (apiPath.startsWith('/article/') && apiPath.includes('/property')) return 'Property';
if (apiPath.startsWith('/article/') && apiPath.includes('/attributevalue')) return 'Attribute';
if (apiPath.startsWith('/article')) return 'Article';
if (apiPath.startsWith('/category/') && apiPath.includes('/property')) return 'Property';
if (apiPath.startsWith('/category/') && apiPath.includes('/attributevalue')) return 'Attribute';
if (apiPath.startsWith('/category')) return 'Category';
if (apiPath.startsWith('/brand')) return 'Brand';
if (apiPath.startsWith('/unit')) return 'Unit';
if (apiPath.startsWith('/public')) return 'PublicCatalog';
if (apiPath.startsWith('/supplier')) return 'Supplier';
if (apiPath.startsWith('/delivery')) return 'Delivery';
if (apiPath.startsWith('/quote')) return 'Quote';
if (apiPath.startsWith('/order')) return 'Order';
if (apiPath.startsWith('/shipment')) return 'Shipment';
if (apiPath.startsWith('/shipping')) return 'Shipping';
if (apiPath.startsWith('/package')) return 'Package';
if (apiPath.startsWith('/stock')) return 'Stock';
if (apiPath.startsWith('/ledger')) return 'Ledger';
if (apiPath.startsWith('/warehouse')) return 'Warehouse';
if (apiPath.startsWith('/account')) return 'Account';
if (apiPath.startsWith('/payment')) return 'Payment';
if (apiPath.startsWith('/image') || apiPath.startsWith('/thumb') || apiPath.startsWith('/pic')) return 'Image';
if (apiPath.startsWith('/serper')) return 'Serper';
if (apiPath.startsWith('/vfs')) return 'Vfs';
if (apiPath.startsWith('/posterminal')) return 'POSTerminal';
if (apiPath.startsWith('/printer')) return 'Printer';
if (apiPath.startsWith('/labeltemplate')) return 'LabelTemplate';
if (apiPath.startsWith('/v1')) return 'POSSync';
if (apiPath.startsWith('/modelconversation')) return 'ModelConversation';
if (apiPath.startsWith('/mcp')) return 'MCP';
if (apiPath.startsWith('/events')) return 'Events';
if (apiPath.startsWith('/health')) return 'Health';
if (apiPath.startsWith('/telemetry')) return 'Telemetry';
return tag || 'Other';
}
function getResourceBasePath(resourceId) {
const map = {
Auth: '/auth',
Company: '/company',
CompanySetting: '/companysetting',
Customergroup: '/customergroup',
Number: '/number',
Entity: '/entity',
Article: '/article',
Category: '/category',
Brand: '/brand',
Unit: '/unit',
PublicCatalog: '/public',
Property: '/propertyname',
Attribute: '/attributename',
Supplier: '/supplier',
ArticleSupplier: '/article/{sku}/supplier',
Delivery: '/delivery',
Quote: '/quote',
Order: '/order',
Shipment: '/shipment',
Shipping: '/shipping',
Package: '/package',
Stock: '/stock',
Ledger: '/ledger',
Warehouse: '/warehouse',
Account: '/account',
Payment: '/payment',
Image: '/image',
Serper: '/serper',
Vfs: '/vfs',
POSTerminal: '/posterminal',
Printer: '/printer',
LabelTemplate: '/labeltemplate',
POSSync: '/v1',
ModelConversation: '/modelconversation',
MCP: '/mcp',
Events: '/events',
Health: '/health',
Telemetry: '/telemetry',
Legend: '',
};
return map[resourceId] ?? `/${resourceId.toLowerCase()}`;
}
function cleanSubpath(apiPath, basePath) {
if (apiPath === basePath) return '/';
if (apiPath.startsWith(basePath)) {
const sub = apiPath.slice(basePath.length);
return sub.startsWith('/') ? sub : `/${sub}`;
}
return apiPath;
}
function truncate(str, max = 28) {
if (!str) return '';
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
}
function sanitizePort(name) {
return name.replace(/[^a-zA-Z0-9_]/g, '_');
}
function parseApi(spec) {
const resources = new Map();
for (const [apiPath, pathItem] of Object.entries(spec.paths || {})) {
for (const [method, op] of Object.entries(pathItem || {})) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) continue;
if (!op || typeof op !== 'object') continue;
const methodUpper = method.toUpperCase();
const tag = Array.isArray(op.tags) && op.tags[0] ? op.tags[0] : '';
const resourceId = assignResource(apiPath, methodUpper, tag);
const basePath = getResourceBasePath(resourceId);
if (!resources.has(resourceId)) {
resources.set(resourceId, {
id: resourceId,
basePath,
endpoints: [],
});
}
const res = resources.get(resourceId);
const subpath = cleanSubpath(apiPath, basePath);
const summary = op.summary || op.description || '';
const portId = sanitizePort(`ep_${methodUpper.toLowerCase()}_${apiPath}`);
res.endpoints.push({
method: methodUpper,
path: apiPath,
subpath,
portId,
summary: truncate(summary, 28),
operation: op,
});
}
}
// Sort endpoints within each resource: root subpath first, then by method rank, then path
const methodRank = { GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5 };
for (const res of resources.values()) {
res.endpoints.sort((a, b) => {
if (a.subpath === '/' && b.subpath !== '/') return -1;
if (b.subpath === '/' && a.subpath !== '/') return 1;
const rankDiff = (methodRank[a.method] ?? 99) - (methodRank[b.method] ?? 99);
if (rankDiff !== 0) return rankDiff;
return a.subpath.localeCompare(b.subpath);
});
}
return resources;
}
function findEndpointPort(res, method, pathSnippet) {
if (!res || !res.endpoints) return null;
const match = res.endpoints.find((ep) => {
if (method && ep.method !== method.toUpperCase()) return false;
if (pathSnippet) {
return ep.path.includes(pathSnippet) || ep.subpath.includes(pathSnippet);
}
return true;
});
return match?.portId ?? null;
}
/**
* Builds high-fidelity relationships between API resources.
*/
function buildApiEdges(resources) {
const edgeDefs = [
// Sales & Fulfillment
{ from: 'Order', to: 'Entity', label: 'entityId', method: 'GET', path: '/order/{id}' },
{ from: 'Order', to: 'Article', label: 'lines.sku', method: 'POST', path: '/order' },
{ from: 'Order', to: 'Shipment', label: 'shipments', method: 'GET', path: '/order/{id}' },
{ from: 'Order', to: 'Payment', label: 'payments', method: 'GET', path: '/order/{id}' },
{ from: 'Order', to: 'Shipping', label: 'shippings', method: 'GET', path: '/order/{id}' },
{ from: 'Quote', to: 'Entity', label: 'entityId', method: 'GET', path: '/quote/{id}' },
{ from: 'Quote', to: 'Article', label: 'lines.sku', method: 'POST', path: '/quote' },
{ from: 'Quote', to: 'Order', label: 'convert order', method: 'POST', path: '/quote' },
{ from: 'Shipment', to: 'Order', label: 'manifestId', method: 'GET', path: '/shipment/{id}' },
{ from: 'Shipment', to: 'Article', label: 'lines.sku', method: 'GET', path: '/shipment/{id}' },
{ from: 'Shipment', to: 'Shipping', label: 'shippings', method: 'GET', path: '/shipment/{id}/shipping' },
{ from: 'Shipping', to: 'Package', label: 'packages', method: 'GET', path: '/shipping/{id}' },
{ from: 'Package', to: 'Shipping', label: 'shippingId', method: 'POST', path: '/package' },
{ from: 'Package', to: 'Entity', label: 'addressId', method: 'POST', path: '/package' },
// Purchasing & Goods receipt
{ from: 'Delivery', to: 'Supplier', label: 'supplierId', method: 'POST', path: '/delivery' },
{ from: 'Delivery', to: 'Article', label: 'lines.sku', method: 'POST', path: '/delivery' },
{ from: 'Delivery', to: 'Ledger', label: 'ledger', method: 'GET', path: '/delivery/{id}' },
{ from: 'ArticleSupplier', to: 'Supplier', label: 'entityId', method: 'POST', path: '/supplier' },
{ from: 'ArticleSupplier', to: 'Article', label: 'sku', method: 'GET', path: '/supplier' },
{ from: 'Supplier', to: 'Entity', label: 'entityId', method: 'GET', path: '/supplier/{id}' },
// Inventory & Warehousing
{ from: 'Stock', to: 'Article', label: 'sku', method: 'POST', path: '/stock' },
{ from: 'Stock', to: 'Warehouse', label: 'warehouseId', method: 'POST', path: '/stock' },
{ from: 'Stock', to: 'Ledger', label: 'ledger', method: 'POST', path: '/stock' },
{ from: 'Ledger', to: 'Article', label: 'sku', method: 'GET', path: '/ledger' },
{ from: 'Ledger', to: 'Warehouse', label: 'warehouseId', method: 'GET', path: '/ledger' },
// Catalog & Products
{ from: 'Article', to: 'Category', label: 'categoryId', method: 'POST', path: '/article' },
{ from: 'Article', to: 'Brand', label: 'brand', method: 'PUT', path: '/brand' },
{ from: 'Article', to: 'Unit', label: 'unitSi', method: 'PATCH', path: '/article/{sku}' },
{ from: 'Article', to: 'ArticleSupplier', label: 'suppliers', method: 'GET', path: '/article/{sku}' },
{ from: 'Article', to: 'Image', label: 'photos', method: 'POST', path: '/image' },
{ from: 'Article', to: 'Customergroup', label: 'prices', method: 'PUT', path: '/prices' },
{ from: 'Article', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
{ from: 'Article', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
{ from: 'Article', to: 'Article', label: 'bundles & variants', method: 'GET', path: '/children' },
{ from: 'Category', to: 'Category', label: 'parentId', method: 'GET', path: '/category' },
{ from: 'Category', to: 'Article', label: 'articles', method: 'GET', path: '/category/{id}/article' },
{ from: 'Category', to: 'Image', label: 'image', method: 'POST', path: '/image' },
{ from: 'Category', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
{ from: 'Category', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
{ from: 'Brand', to: 'Image', label: 'image', method: 'POST', path: '/image' },
// Properties & Attributes
{ from: 'Property', to: 'Company', label: 'companyId', method: 'GET', path: '/propertyname' },
{ from: 'Attribute', to: 'Image', label: 'image', method: 'POST', path: '/image' },
// Finance & Payments
{ from: 'Payment', to: 'Account', label: 'accountId', method: 'POST', path: '/payment' },
{ from: 'Payment', to: 'Order', label: 'manifestId', method: 'POST', path: '/payment' },
{ from: 'Account', to: 'Company', label: 'companyId', method: 'GET', path: '/account' },
// CRM & Company
{ from: 'Entity', to: 'Customergroup', label: 'customergroupId', method: 'POST', path: '/entity' },
{ from: 'Company', to: 'Customergroup', label: 'poscustomergroup', method: 'GET', path: '/company/{id}' },
{ from: 'Company', to: 'Category', label: 'root_category', method: 'GET', path: '/company/{id}' },
{ from: 'Company', to: 'CompanySetting', label: 'settings', method: 'GET', path: '/company/{id}' },
{ from: 'Number', to: 'Company', label: 'companyId', method: 'GET', path: '/number' },
// POS & Hardware
{ from: 'POSTerminal', to: 'Company', label: 'companyId', method: 'GET', path: '/posterminal' },
{ from: 'POSTerminal', to: 'Category', label: 'root_category', method: 'GET', path: '/posterminal' },
{ from: 'Printer', to: 'Company', label: 'companyId', method: 'GET', path: '/printer' },
{ from: 'Printer', to: 'LabelTemplate', label: 'templates', method: 'GET', path: '/printer' },
{ from: 'LabelTemplate', to: 'Company', label: 'companyId', method: 'GET', path: '/labeltemplate' },
{ from: 'POSSync', to: 'POSTerminal', label: 'client bind', method: 'POST', path: '/client' },
{ from: 'POSSync', to: 'Article', label: 'product sync', method: 'GET', path: '/product' },
{ from: 'POSSync', to: 'Category', label: 'category sync', method: 'GET', path: '/category' },
// AI, Tools & Public
{ from: 'PublicCatalog', to: 'Article', label: 'articles', method: 'GET', path: '/product' },
{ from: 'PublicCatalog', to: 'Category', label: 'category', method: 'GET', path: '/category' },
{ from: 'ModelConversation', to: 'Vfs', label: 'vfs workspace', method: 'GET', path: '/modelconversation' },
{ from: 'MCP', to: 'Article', label: 'article tools', method: 'POST', path: '/mcp' },
{ from: 'MCP', to: 'Order', label: 'order tools', method: 'POST', path: '/mcp' },
{ from: 'Events', to: 'Stock', label: 'stock sse', method: 'GET', path: '/events' },
{ from: 'Events', to: 'Ledger', label: 'ledger sse', method: 'GET', path: '/events' },
{ from: 'Events', to: 'Article', label: 'article sse', method: 'GET', path: '/events' },
];
const edges = [];
for (const def of edgeDefs) {
if (!resources.has(def.from) || !resources.has(def.to)) continue;
const resFrom = resources.get(def.from);
const resolvedPort = findEndpointPort(resFrom, def.method, def.path);
edges.push({
from: def.from,
to: def.to,
label: def.label,
port: resolvedPort,
tooltip: `${def.from} → ${def.to} (${def.label})`,
inferred: false,
});
}
return edges;
}
function edgeKey(edge) {
return `${edge.from}|${edge.to}|${edge.label}`;
}
function assignEdgeColors(edges) {
const colors = new Map();
let next = 0;
for (const edge of edges) {
const key = edgeKey(edge);
if (!colors.has(key)) {
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
next++;
}
}
return colors;
}
/**
* Spanning ranking forest: only one primary outgoing edge per resource
* is used for rank constraints. Other edges get constraint=false to avoid
* vertical over-stretching.
*/
function selectRankingEdgeKeys(edges) {
const ranking = new Set();
const bySource = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const group of bySource.values()) {
group.sort((a, b) => a.label.localeCompare(b.label));
ranking.add(edgeKey(group[0]));
}
return ranking;
}
function buildLegendLabel() {
const methodRow = (method, desc) => {
const c = METHOD_COLORS[method];
return (
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="38">` +
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${method}</B></FONT></TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
);
};
const row = (swatch, text) =>
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>API Graph Legend</B></FONT></TD></TR>` +
methodRow('GET', 'Safe retrieval / query') +
methodRow('POST', 'Create resource / action') +
methodRow('PUT', 'Full replace / collection update') +
methodRow('PATCH', 'Partial entity update') +
methodRow('DELETE', 'Remove resource') +
row(
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
'Inbound arrival port on resource'
) +
row(
`<FONT COLOR="#5aa7ff">&#9642;&#8212;&#9654;</FONT>`,
'Relational reference / dataflow'
) +
`</TABLE>>`
);
}
function renderResourceNode(res, inboundCount = 0) {
if (res.id === 'Legend') {
return buildLegendLabel();
}
const titleFont =
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(res.id)}</B></FONT>` +
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(res.basePath)}</FONT>`;
let headerInner;
if (inboundCount > 0) {
const portCells = [];
for (let i = 0; i < inboundCount; i++) {
portCells.push(
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
);
}
headerInner =
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>${portCells.join('')}</TR>` +
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
`</TABLE>`;
} else {
headerInner = titleFont;
}
const rows = [
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
];
for (const [i, ep] of res.endpoints.entries()) {
const mColor = METHOD_COLORS[ep.method] ?? { fg: COLORS.text, bg: COLORS.rowBg };
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
rows.push(
`<TR><TD PORT="${escapeHtml(ep.portId)}" COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>` +
`<TD BGCOLOR="${mColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="38">` +
`<FONT COLOR="${mColor.fg}" POINT-SIZE="9"><B>${escapeHtml(ep.method)}</B></FONT></TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(ep.subpath)}</B></FONT></TD>` +
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(ep.summary)}</FONT></TD>` +
`</TR></TABLE></TD></TR>`
);
}
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
rows.join('') +
`</TABLE>>`
);
}
function assignEdgeAnchors(edges, positions) {
const headPort = new Map();
const tailCompass = new Map();
const inboundCounts = new Map();
const byTarget = new Map();
const bySource = new Map();
for (const edge of edges) {
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
byTarget.get(edge.to).push(edge);
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const [target, group] of byTarget) {
group.sort((a, b) => {
const ax = positions?.get(a.from)?.x ?? 0;
const bx = positions?.get(b.from)?.x ?? 0;
if (ax !== bx) return ax - bx;
return a.label.localeCompare(b.label);
});
inboundCounts.set(target, group.length);
group.forEach((edge, i) => {
headPort.set(edgeKey(edge), `in${i}`);
});
}
for (const group of bySource.values()) {
group.forEach((edge, i) => {
if (edge.from === edge.to) {
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
return;
}
const from = positions?.get(edge.from);
const to = positions?.get(edge.to);
const fromX = from?.x ?? 0;
const toX = to?.x ?? fromX;
const goEast = toX >= fromX;
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
});
}
return { headPort, tailCompass, inboundCounts };
}
function buildDot(resources, edges, positions, anchors) {
const edgeColors = assignEdgeColors(edges);
const rankingKeys = selectRankingEdgeKeys(edges);
const { headPort, tailCompass, inboundCounts } =
anchors ?? assignEdgeAnchors(edges, positions);
const lines = [
'digraph api {',
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, mclimit=1.0];',
' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
'',
];
const clusteredResources = new Set();
for (const cluster of CLUSTERS) {
const presentInCluster = cluster.resources.filter((id) => resources.has(id));
if (presentInCluster.length === 0) continue;
lines.push(` subgraph cluster_${cluster.id} {`);
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
for (const resId of presentInCluster) {
clusteredResources.add(resId);
const res = resources.get(resId);
const inCount = inboundCounts.get(resId) ?? 0;
const label = renderResourceNode(res, inCount);
lines.push(` ${resId} [label=${label}];`);
}
lines.push(' }');
}
// Any unclustered resources
for (const [resId, res] of resources) {
if (clusteredResources.has(resId)) continue;
const inCount = inboundCounts.get(resId) ?? 0;
const label = renderResourceNode(res, inCount);
lines.push(` ${resId} [label=${label}];`);
}
lines.push('');
for (const edge of edges) {
const key = edgeKey(edge);
const color = edgeColors.get(key) ?? '#5aa7ff';
const isRanking = rankingKeys.has(key);
const hp = headPort.get(key) ?? 'in0';
const tc = tailCompass.get(key) ?? 'e';
const attrs = [
`color="${color}"`,
`fontcolor="${color}"`,
`tooltip="${escapeHtml(edge.tooltip)}"`,
`label=" ${escapeHtml(edge.label)} "`,
];
if (!isRanking && edge.from !== edge.to) {
attrs.push('constraint=false', 'weight=1');
} else {
attrs.push('weight=3');
}
// Source port from endpoint if available, else exit compass
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
const toPort = `:${hp}:n`;
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
}
lines.push('}');
return lines.join('\n');
}
function parsePlainPositions(plain) {
const positions = new Map();
for (const line of plain.split('\n')) {
if (!line.startsWith('node ')) continue;
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
if (!match) continue;
positions.set(match[1], {
x: Number(match[2]),
y: Number(match[3]),
width: Number(match[4]),
height: Number(match[5]),
});
}
return positions;
}
function fixSelfLoopSvg(svg) {
return svg.replace(
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?&#45;&gt;\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
if (pts.length < 2) return match;
const [x0, y0] = pts[0];
const [x1, y1] = pts[pts.length - 1];
const east = Math.max(x0, x1) + 36;
const north = Math.min(y0, y1) - 28;
const tx = x1 - east;
const ty = y1 - north;
const len = Math.hypot(tx, ty) || 1;
const dx = tx / len;
const dy = ty / len;
const arrowLen = 9;
const arrowHalf = 3.5;
const baseX = x1 - dx * arrowLen;
const baseY = y1 - dy * arrowLen;
const px = -dy * arrowHalf;
const py = dx * arrowHalf;
const fmt = (n) => (Math.round(n * 100) / 100).toString();
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
const newPointsFmt =
`${fmt(x1)},${fmt(y1)} ` +
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
`${fmt(x1)},${fmt(y1)}`;
return (
`<g id="${edgeId}" class="edge">\n` +
`<title>${_table}&#45;&gt;${_table}</title>\n` +
`<g id="${aId}"><a ${aAttrs}>\n` +
`<path${pathAttrs} d="${newDFmt}"/>\n` +
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
`</a>\n` +
`</g>\n` +
`</g>`
);
}
);
}
console.log('Generating Dash API Graph…');
const spec = getOpenApiSpec(true);
const resources = parseApi(spec);
// Add Legend pseudo-resource
resources.set('Legend', {
id: 'Legend',
basePath: '',
endpoints: [],
});
const edges = buildApiEdges(resources);
console.log(`Parsed ${resources.size - 1} API resources and ${edges.length} relationships`);
const graphviz = await Graphviz.load();
// Pass 1: Initial layout to learn coordinates
console.log('Layout pass 1/2: computing node positions…');
const initialDot = buildDot(resources, edges, null, null);
const plain = graphviz.layout(initialDot, 'plain', 'dot');
const positions = parsePlainPositions(plain);
// Pass 2: Anchor assignment using learned positions
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
const anchors = assignEdgeAnchors(edges, positions);
const finalDot = buildDot(resources, edges, positions, anchors);
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
const fixedSvg = fixSelfLoopSvg(rawSvg);
// Ensure background color is set on svg tag
const finalSvg = fixedSvg.replace(
/<svg([^>]*?)>/,
`<svg$1 style="background-color:${COLORS.background};">`
);
writeFileSync(dotOutputPath, finalDot, 'utf8');
writeFileSync(outputPath, finalSvg, 'utf8');
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
console.log(`Written ${dotOutputPath}`);
console.log(`Written ${outputPath}`);
if (dim) {
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
}

BIN
img2.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

BIN
img3.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

371
mcp.md Normal file
View File

@@ -0,0 +1,371 @@
# Model Context Protocol (MCP) in Dashback
This document explains the architecture, database schema, OAuth 2.1 authentication flow, and endpoint implementation of the Model Context Protocol (MCP) server in Dashback.
---
## 1. Overview & Purpose
The Dashback backend provides an MCP server running over **Streamable HTTP** at `/mcp`. It exposes the internal **`dashback_request`** tool (the same tool provided to the local agent via `src/tools/dashback-request.js`) to external MCP clients such as:
- Visual Studio Code / GitHub Copilot
- Claude Desktop
- Cursor / Windsurf
All operations executed via `dashback_request` are cryptographically authenticated and scoped to the authorizing Dashback user (`google_auth_id`) and their selected company.
---
## 2. Authentication: OAuth 2.1 Specification Compliance
MCP remote HTTP transports require standardized authorization following [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13). Dashback implements a full, built-in OAuth 2.1 authorization server supporting:
| Standard | Purpose | Endpoint(s) |
|---|---|---|
| **RFC 9728** | Protected Resource Metadata (PRM) | `/.well-known/oauth-protected-resource`<br>`/.well-known/oauth-protected-resource/mcp` |
| **RFC 8414** / OIDC | Authorization Server Metadata | `/.well-known/oauth-authorization-server`<br>`/.well-known/openid-configuration` |
| **RFC 7591** | Dynamic Client Registration (DCR) | `POST /oauth/register` |
| **RFC 6749** & **RFC 7636** | Authorization Code Flow with PKCE (`S256`) | `GET /oauth/authorize`<br>`POST /oauth/authorize` |
| **RFC 6749** | Token Exchange & Refresh | `POST /oauth/token` |
| **RFC 7662** | Token Introspection | `POST /oauth/introspect` |
| **RFC 8252** §7.3 | Native App Loopback Interface Redirection | Port-agnostic redirect URI matching for `127.0.0.1` and `localhost` |
---
## 3. Database Schema (`dashdbschema`)
Three new tables were added to both **MSSQL** (`src/providers/mssql/schema.js`) and **SQLite** (`src/providers/sqlite3/schema.js`):
### 3.1. `mcp_client`
Stores dynamically registered MCP clients (e.g., VS Code instances).
- `id` (INTEGER / INT IDENTITY, PK)
- `client_id` (NVARCHAR(100) / TEXT, UNIQUE NOT NULL)
- `client_secret` (NVARCHAR(255) / TEXT, NULL)
- `client_name` (NVARCHAR(255) / TEXT, NOT NULL)
- `redirect_uris` (NVARCHAR(MAX) / TEXT, NOT NULL, JSON array or URI list)
- `grant_types` (NVARCHAR(255) / TEXT, NULL, e.g. `'authorization_code,refresh_token'`)
- `response_types` (NVARCHAR(255) / TEXT, NULL, e.g. `'code'`)
- `scope` (NVARCHAR(255) / TEXT, NULL, default `'mcp:tools'`)
- `created_at` (DATETIME2 / TEXT)
### 3.2. `mcp_auth_code`
Stores short-lived authorization codes (5-minute TTL) during the PKCE handshake.
- `code` (NVARCHAR(128) / TEXT, PK)
- `client_id` (NVARCHAR(100) / TEXT, NOT NULL)
- `google_auth_id` (INT / INTEGER, FK `google_auth(id) ON DELETE CASCADE`)
- `redirect_uri` (NVARCHAR(2048) / TEXT, NOT NULL)
- `code_challenge` (NVARCHAR(255) / TEXT, NOT NULL)
- `code_challenge_method` (NVARCHAR(32) / TEXT, NOT NULL, e.g. `'S256'`)
- `scope` (NVARCHAR(255) / TEXT, NULL)
- `expires_at` (DATETIME2 / TEXT, NOT NULL)
- `used` (TINYINT / INTEGER, NOT NULL DEFAULT 0)
- `created_at` (DATETIME2 / TEXT)
### 3.3. `mcp_token`
Stores refresh tokens (30-day TTL) for long-lived MCP connections.
- `id` (INTEGER / INT IDENTITY, PK)
- `refresh_token` (NVARCHAR(128) / TEXT, UNIQUE NOT NULL)
- `client_id` (NVARCHAR(100) / TEXT, NOT NULL)
- `google_auth_id` (INT / INTEGER, FK `google_auth(id) ON DELETE CASCADE`)
- `scope` (NVARCHAR(255) / TEXT, NULL)
- `expires_at` (DATETIME2 / TEXT, NOT NULL)
- `revoked` (TINYINT / INTEGER, NOT NULL DEFAULT 0)
- `created_at` (DATETIME2 / TEXT)
### Store Methods (`libdashstore`)
Under `src/store/mcp/` in `dashdbschema`:
- `store.mcp.registerClient(input)`
- `store.mcp.getClient(clientId)`
- `store.mcp.listClients()`
- `store.mcp.deleteClient(clientId)`
- `store.mcp.createAuthCode(input)`
- `store.mcp.getAuthCode(code)`
- `store.mcp.consumeAuthCode(code)`
- `store.mcp.createRefreshToken(input)`
- `store.mcp.getRefreshToken(refreshToken)`
- `store.mcp.revokeRefreshToken(refreshToken)`
Wrapped with execution timing logs in `dashback` via `src/db/mcp.js` and `STORE_NAMESPACES`.
---
## 4. Endpoints & Flow Breakdown
All routes are defined in [`src/api/routes/mcp.js`](src/api/routes/mcp.js).
### Step 1: Protected Resource Discovery
When an MCP client connects to `POST /mcp` without credentials:
- Server responds with **`401 Unauthorized`** and header:
```http
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="http://<host>:4208/.well-known/oauth-protected-resource"
```
- The client queries `GET /.well-known/oauth-protected-resource`, receiving:
```json
{
"resource": "http://<host>:4208/mcp",
"authorization_servers": ["http://<host>:4208"],
"scopes_supported": ["mcp:tools"],
"resource_name": "Dashback MCP Server"
}
```
### Step 2: Authorization Server Discovery
The client queries `GET /.well-known/oauth-authorization-server`, receiving:
```json
{
"issuer": "http://<host>:4208",
"authorization_endpoint": "http://<host>:4208/oauth/authorize",
"token_endpoint": "http://<host>:4208/oauth/token",
"registration_endpoint": "http://<host>:4208/oauth/register",
"introspection_endpoint": "http://<host>:4208/oauth/introspect",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["mcp:tools"]
}
```
### Step 3: Dynamic Client Registration
The client registers with `POST /oauth/register`:
```json
{
"client_name": "Visual Studio Code",
"redirect_uris": [
"https://vscode.dev/redirect",
"http://127.0.0.1/",
"http://127.0.0.1:33418/"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}
```
The server stores the client in `mcp_client` and returns status `201 Created` with a unique `client_id` (UUID).
### Step 4: User Authorization & Consent via Frontend
When the MCP client directs the user's browser to authenticate:
- The MCP client reads `authorization_endpoint` from `/.well-known/oauth-authorization-server`, which resolves to `http://<host>:4209/oauth/authorize`.
- If an agent or client hits `GET /oauth/authorize` directly on the backend, the backend redirects (HTTP 302) to `${frontendUrl}/oauth/authorize?<queryParams>`.
- The user is authenticated in the Dashfront frontend SPA.
- The frontend calls `GET /api/mcp/authorize?<queryParams>` to fetch client details and user context for the consent dialog.
- When the user clicks **Authorize**, the frontend calls `POST /api/mcp/authorize` (`action: "allow"`).
- The server generates a single-use authorization code (in `mcp_auth_code`) and returns `{ ok: true, redirectUrl: "http://127.0.0.1:<port>/?code=<code>&state=<state>" }`.
- The frontend sets `window.location.href = data.redirectUrl`, delivering the code to the client loopback listener.
- **Loopback Port Matching (RFC 8252 §7.3)**: Native applications bind to ephemeral ports (e.g. `54754`). The server uses `isRedirectUriAllowed()` to permit any port on `127.0.0.1` and `localhost` when the registered URI is on loopback.
### Step 5: Token Exchange
The MCP client sends a POST request with PKCE verifier to `POST /oauth/token`:
```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<code>&client_id=<id>&redirect_uri=...&code_verifier=<verifier>
```
The server:
1. Validates `code`, client ID, expiration, and unused status.
2. Computes `BASE64URL(SHA256(code_verifier))` and compares it against `code_challenge`.
3. Marks the code as used in `mcp_auth_code`.
4. Issues a signed JWT access token (1-hour TTL) with payload `{ sub: userId, id: userId, client_id, scope: "mcp:tools", aud: "<host>/mcp" }`.
5. Issues a 30-day random hex refresh token stored in `mcp_token`.
### Step 6: MCP Session Handling (`/mcp`)
The client connects to `/mcp` with `Authorization: Bearer <access_token>`.
- **`requireBearerAuth`**: Validates the JWT and verifies that the `userId` exists and is active in `google_auth`.
- **`StreamableHTTPServerTransport`** (`@modelcontextprotocol/sdk`):
- `POST /mcp`: Handles session `initialize` (generating a session UUID and returning `Mcp-Session-Id` header) and subsequent JSON-RPC tool calls.
- `GET /mcp`: Handles server-sent events (SSE) streaming connections.
- `DELETE /mcp`: Closes the transport session.
---
## 5. The Exposed Tool: `dashback_request`
The MCP server registers the exact same tool exposed to the internal local agent (`src/tools/dashback-request.js`):
```javascript
server.registerTool('dashback_request', {
title: 'Dashback API Request',
description: 'Perform HTTP requests against the Dashback API',
inputSchema: {
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
path: z.string().describe('e.g. /articles, /orders, /category'),
query: z.record(z.string(), z.any()).nullable().optional(),
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional(),
note: z.string().nullable().optional(),
},
}, async ({ method = 'GET', path, query = null, body = null, note }) => {
// Generates a 5-minute JWT signed with env.jwtSecret scoped to the authenticated user's ID
// Performs fetch against http://127.0.0.1:<port>/api<path>
// Returns status and truncated body (up to 20,000 characters)
});
```
Because the internal fetch uses a JWT containing the user's `google_auth_id`, all Dashback route-level permissions, company isolation (`req.user.selectedCompanyId`), audit logging, and data scoping apply automatically.
---
## 6. How to Connect from VS Code / GitHub Copilot
### Configuration
Add the server to your VS Code MCP configuration (either in `.vscode/mcp.json` or your global VS Code settings):
```json
{
"dashback": {
"url": "http://127.0.0.1:4208/mcp",
"type": "http"
}
}
```
*(If connecting from another device over LAN, use `http://<server-ip>:4208/mcp`)*.
### First Connection Flow:
1. Open VS Code with Copilot Chat.
2. VS Code contacts `/mcp`, triggers OAuth 2.1 discovery, and asks you to open a browser for authorization.
3. Click **Open** and press **Authorize** on the Dashback consent screen.
4. VS Code receives the tokens and displays `#dashback_request` in Copilot Chat tools.
---
## 7. Frontend Developer Implementation Guide
The frontend (`dashfront`, port 4209) needs two views / features:
1. **Consent Screen Route (`/oauth/authorize`)**: Where external MCP clients send users to approve access.
2. **Authorizations Management (Settings/Security)**: Where users view authorized MCP clients and revoke access.
### 7.1. Consent Screen Route: `/oauth/authorize`
When an MCP client initiates authentication, the user is navigated to:
```
http://<host>:4209/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&code_challenge=...&code_challenge_method=S256&state=...&scope=mcp:tools
```
#### Step 1: Ensure User Authentication
- Check if the user is authenticated in Dashfront.
- If not logged in, redirect to `/login` preserving the full URL as `returnTo` so they return here after Google SSO.
#### Step 2: Fetch Authorization Prompt Metadata
The frontend makes an authenticated GET request:
```http
GET /api/mcp/authorize?<all_query_params_from_url>
```
**Response (200 OK):**
```json
{
"client": {
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
"clientName": "Visual Studio Code",
"scope": "mcp:tools"
},
"user": {
"id": 1,
"email": "user@example.com"
},
"params": {
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
"redirectUri": "http://127.0.0.1:54754/",
"codeChallenge": "...",
"codeChallengeMethod": "S256",
"state": "...",
"scope": "mcp:tools"
}
}
```
#### Step 3: Render Consent UI
Display a clean dialog or card:
- Application requesting access: `client.clientName`
- Authorizing account: `user.email`
- Permissions requested: `dashback_request` API tools (`mcp:tools`)
- Action buttons:
- **Authorize** (Allow)
- **Cancel** (Deny)
#### Step 4: Submit Consent Decision
When the user clicks **Authorize**:
```http
POST /api/mcp/authorize
Content-Type: application/json
{
"client_id": "<params.clientId>",
"redirect_uri": "<params.redirectUri>",
"code_challenge": "<params.codeChallenge>",
"code_challenge_method": "<params.codeChallengeMethod>",
"state": "<params.state>",
"scope": "<params.scope>",
"action": "allow"
}
```
**Response (200 OK):**
```json
{
"ok": true,
"redirectUrl": "http://127.0.0.1:54754/?code=<auth_code>&state=<state>"
}
```
The frontend immediately navigates the browser:
```javascript
window.location.href = res.data.redirectUrl;
```
When the user clicks **Cancel**:
```http
POST /api/mcp/authorize
Content-Type: application/json
{
"client_id": "<params.clientId>",
"redirect_uri": "<params.redirectUri>",
"state": "<params.state>",
"action": "deny"
}
```
**Response (200 OK):**
```json
{
"ok": false,
"redirectUrl": "http://127.0.0.1:54754/?error=access_denied&state=<state>"
}
```
Navigate: `window.location.href = res.data.redirectUrl;`.
---
### 7.2. Authorizations Management (Settings / Security)
In the user profile or security settings, users can view active MCP client authorizations and revoke them.
#### List Active Authorizations
```http
GET /api/mcp/authorizations
```
**Response (200 OK):**
```json
{
"authorizations": [
{
"clientDbId": 3,
"clientId": "0a2e6bbb-05a5-4b03-bed1-9b853b5aec34",
"clientName": "Visual Studio Code",
"scope": "mcp:tools",
"clientCreatedAt": "2026-10-12T20:36:46.036Z",
"authorizedAt": "2026-10-12T20:39:28.148Z",
"tokenCount": 1
}
]
}
```
#### Revoke an Authorization
When the user clicks **Revoke** / **Delete**:
```http
DELETE /api/mcp/authorizations/:clientId
```
**Response (200 OK):**
```json
{
"ok": true,
"message": "Authorizations revoked"
}
```
This immediately revokes all active refresh tokens for that client and the user. Any future token refresh requests by that client will be rejected with `invalid_grant`.

220
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"better-sqlite3": "^13.0.3",
"chalk": "^6.0.1",
"cookie-parser": "^1.4.7",
@@ -32,9 +33,11 @@
"sharp": "^0.35.5",
"skelehash-wasm": "^0.1.0",
"swagger-ui-dist": "^5.33.1",
"vm2": "^3.12.2"
"vm2": "^3.12.2",
"zod": "^4.6.7"
},
"devDependencies": {
"@hpcc-js/wasm-graphviz": "^1.29.2",
"acorn": "^8.19.0",
"swagger-jsdoc": "^6.3.0"
}
@@ -198,6 +201,25 @@
"lodash.uniq": "^4.5.0"
}
},
"node_modules/@hono/node-server": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz",
"integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"hono": "^4 || ^5.0.0-0"
}
},
"node_modules/@hpcc-js/wasm-graphviz": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.29.2.tgz",
"integrity": "sha512-AkArBAZERT5OxH52/+kvCHB07cZ6ZNd9GnOvjw+S880bR88D6i27XfK6xj4dBhUDxNA2QPPMdgEcky3x4TJ3KA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -756,6 +778,55 @@
"node": ">=18"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.32.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
"integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
@@ -1046,7 +1117,6 @@
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1074,6 +1144,23 @@
}
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -1887,6 +1974,23 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -2458,6 +2562,27 @@
"node": ">= 0.6"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -2530,6 +2655,25 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -2568,7 +2712,6 @@
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
"type": "github",
@@ -3082,6 +3225,15 @@
"node": ">= 0.4"
}
},
"node_modules/hono": {
"version": "4.13.13",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3294,6 +3446,15 @@
"node": ">= 0.4"
}
},
"node_modules/ip-address": {
"version": "10.7.4",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
"integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -3704,6 +3865,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@@ -3746,9 +3916,14 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -4354,6 +4529,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4598,6 +4782,15 @@
"png-js": "^2.0.0"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkijs": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz",
@@ -4966,7 +5159,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6443,6 +6635,24 @@
"engines": {
"node": ">= 10"
}
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
}
}
}

View File

@@ -12,10 +12,12 @@
"manage-users": "node scripts/manageUsers.js",
"wipe-company": "node scripts/wipeCompany.js",
"docs:api": "node scripts/generateApiDocs.js",
"api-graph": "node generate-api-graph.js",
"review:functions": "node scripts/extractFunctions.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"better-sqlite3": "^13.0.3",
"chalk": "^6.0.1",
"cookie-parser": "^1.4.7",
@@ -39,9 +41,11 @@
"sharp": "^0.35.5",
"skelehash-wasm": "^0.1.0",
"swagger-ui-dist": "^5.33.1",
"vm2": "^3.12.2"
"vm2": "^3.12.2",
"zod": "^4.6.7"
},
"devDependencies": {
"@hpcc-js/wasm-graphviz": "^1.29.2",
"acorn": "^8.19.0",
"swagger-jsdoc": "^6.3.0"
},

View File

@@ -72,6 +72,17 @@ function isAllowedCorsOrigin(origin) {
function corsMiddleware(req, res, next) {
const origin = req.headers.origin;
if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path.startsWith('/oauth')) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
return next();
}
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
@@ -121,7 +132,7 @@ function corsMiddleware(req, res, next) {
next();
}
async function buildApiApp() {
export async function buildApiApp() {
const app = express();
app.use((req, res, next) => {
@@ -133,6 +144,7 @@ async function buildApiApp() {
});
app.use(corsMiddleware);
app.use(express.json({ limit: env.bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: env.bodyLimit }));
app.use(cookieParser());
app.use(loggerMiddleware);
app.use(authMiddleware);

View File

@@ -63,6 +63,12 @@ export const authMiddleware = async (req, res, next) => {
return next();
}
if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path === '/oauth/register' || req.path === '/oauth/token' || req.path === '/oauth/introspect') {
return next();
}
const isOAuthAuthorize = req.path === '/oauth/authorize';
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7).trim()
: null;
@@ -70,7 +76,7 @@ export const authMiddleware = async (req, res, next) => {
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
if (!token) {
if (isDocs) return next();
if (isDocs || isOAuthAuthorize) return next();
return respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' });
}
@@ -118,7 +124,7 @@ export const authMiddleware = async (req, res, next) => {
next();
} catch {
if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
if (isDocs || isOAuthAuthorize) return next();
return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' });
}
};

View File

@@ -15,6 +15,17 @@ function logField(label, value, { skipEmpty = false } = {}) {
httpLog.debug(prefix + '\n' + rendered.replace(/^/gm, ' '));
}
function logResponse(value) {
const rendered = prettyJson(value).replace(/\r?\n/g, '');
const prefix = chalk.gray(' response: ');
if (rendered.length <= 100) {
httpLog.debug(prefix + rendered);
return;
}
const cut = rendered.length - 100;
httpLog.debug(prefix + rendered.slice(0, 100) + '\u001b[0m' + chalk.gray(` … [${cut} chars cut]`));
}
function captureResponseBody(res) {
let body;
const originalJson = res.json.bind(res);
@@ -76,7 +87,7 @@ export const loggerMiddleware = (req, res, next) => {
httpLog.debug(chalk.gray(' authorization: ') + chalk.cyan(req.headers.authorization));
}
logField('body', req.body ?? {}, { skipEmpty: true });
logField('response', getBody());
logResponse(getBody());
// Dump full request/response/error details to a log file on 5xx.
const statusCode = res.statusCode;

View File

@@ -42,14 +42,23 @@
* properties:
* id:
* type: integer
* iso639:
* type: string
* name:
* type: string
* rootCategoryId:
* type: integer
* nullable: true
* languageId:
* type: integer
* nullable: true
* iso639:
* type: string
* nullable: true
* taxrate:
* type: number
* reducedtaxrate:
* type: number
* taxfreerate:
* type: number
* SuccessResponse:
* type: object
* required: [ok]
@@ -513,6 +522,9 @@
* thumbHash:
* type: string
* nullable: true
* articleCount:
* type: integer
* description: Number of articles assigned directly to this category
* posterminalIds:
* type: array
* items:
@@ -1011,8 +1023,28 @@
* $ref: '#/components/schemas/Payment'
* paid:
* type: boolean
* description: True when paidTotal equals orderTotal
* shipped:
* type: boolean
* description: True when shippedQuantity equals orderedQuantity
* delivered:
* type: boolean
* orderTotal:
* type: number
* description: Gross sum across order positions
* paidTotal:
* type: number
* description: Sum of all payments connected to order
* orderedQuantity:
* type: number
* description: Total ordered items
* shippedQuantity:
* type: number
* description: Total shipped items across order shipments
* shipments:
* type: array
* items:
* $ref: '#/components/schemas/Shipment'
* ArticleImage:
* type: object
* required: [picId, sort, full, thumb, hash, thumbHash]
@@ -1114,6 +1146,12 @@
* type: string
* enum: [idle, busy]
* description: Messages can only be posted while `idle`
* started_by:
* type: [integer, 'null']
* description: Id of the conversation that started this one as a subagent, or null for a top-level conversation. Deleting a conversation also deletes every conversation below it in this chain.
* first_message:
* type: [string, 'null']
* description: Plain text of the conversation's first user message (XML stripped, max 50 chars, ellipsis if cut), or null if there is none yet.
* ModelConversationCreateInput:
* type: object
* required: [provider, model]
@@ -1191,10 +1229,53 @@
* number:
* type: string
* description: Shipping number from the `shipping` number sequence
* trackingCode:
* type: string
* nullable: true
* description: Carrier parcel tracking code
* carrier:
* type: string
* nullable: true
* description: Carrier name (e.g. DHL, UPS, DPD)
* type:
* type: string
* state:
* type: string
* Shipment:
* type: object
* required: [id, number, type, date, lines, shippings]
* properties:
* id:
* type: integer
* number:
* type: string
* description: Shipment compound number `<orderNumber><shipmentPrefix><y>`
* type:
* type: string
* enum: [shipment]
* entityId:
* type: integer
* nullable: true
* externalNumber:
* type: string
* nullable: true
* date:
* type: string
* createdAt:
* type: string
* nullable: true
* lineCount:
* type: integer
* packageCount:
* type: integer
* lines:
* type: array
* items:
* $ref: '#/components/schemas/ManifestLine'
* shippings:
* type: array
* items:
* $ref: '#/components/schemas/Shipping'
* Package:
* type: object
* required: [id, shippingId, entityaddressId, number, type, state]

View File

@@ -36,6 +36,8 @@ export const openApiOptions = {
{ name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' },
{ name: 'Quote', description: 'Company-scoped quotes (Angebote)' },
{ name: 'Order', description: 'Company-scoped sales orders (Aufträge)' },
{ name: 'Shipment', description: 'Company-scoped shipment manifests (Lieferscheine)' },
{ name: 'Shipping', description: 'Shipment packages, carriers, and tracking codes' },
{ name: 'Customergroup', description: 'Company-scoped customer groups' },
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },

View File

@@ -74,7 +74,7 @@ export function register(app, basePath = '') {
* x-markdown: category/tree.md
* responses:
* 200:
* description: Tree and articles for the selected company
* description: Tree and article count for the selected company
* content:
* application/json:
* schema:
@@ -86,10 +86,9 @@ export function register(app, basePath = '') {
* type: array
* items:
* $ref: '#/components/schemas/CategoryTreeNode'
* articles:
* type: array
* items:
* $ref: '#/components/schemas/CategoryArticle'
* articleCount:
* type: integer
* description: Total number of parent articles across the category tree
* 400:
* description: No company selected
* content:

1102
src/api/routes/mcp.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,9 @@ import * as db from '../../db/modelconversation.js';
import env from '../../config/env.js';
import { RETIRED_MODELS, resolveDefaultModel } from '../../tools/llm-key.js';
import { emit } from '../sse.js';
import { getOrCreateManager, removeManager } from '../../services/conversationManager.js';
import { getOrCreateManager, removeManager, cleanUserMessageContent } from '../../services/conversationManager.js';
const FIRST_MESSAGE_MAX_CHARS = 50;
// #region Helpers
@@ -22,6 +24,50 @@ function parsePositiveInt(value, field) {
return { id };
}
/**
* Removes XML elements together with their content (e.g. `<USERENV><ROUTE>…</ROUTE></USERENV>`)
* and any leftover self-closing tags.
* @param {string} text
* @returns {string}
*/
function stripXmlElements(text) {
const elementPattern = /<([A-Za-z][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/g;
let previous;
let stripped = text;
do {
previous = stripped;
stripped = stripped.replace(elementPattern, '');
} while (stripped !== previous);
return stripped.replace(/<[A-Za-z][\w:.-]*(?:\s[^>]*)?\/>/g, '');
}
/**
* Turns the stored first user message payload (`{"content": ...}`) into short plain text
* for conversation lists: JSON content only, XML elements stripped, outer whitespace trimmed.
* @param {string|null} storedMessage
* @returns {string|null}
*/
function buildFirstMessagePreview(storedMessage) {
if (!storedMessage) return null;
let rawContent;
try {
rawContent = JSON.parse(storedMessage).content;
} catch {
rawContent = storedMessage;
}
const text = stripXmlElements(cleanUserMessageContent(rawContent)).trim();
return text.length > FIRST_MESSAGE_MAX_CHARS ? `${text.slice(0, FIRST_MESSAGE_MAX_CHARS)}…` : text;
}
/**
* Replaces the store's raw `first_user_message` with the plain-text `first_message` preview.
* @param {{ first_user_message: string|null }} row
* @returns {object}
*/
function buildConversationResponse({ first_user_message: storedFirstMessage, ...conversation }) {
return { ...conversation, first_message: buildFirstMessagePreview(storedFirstMessage) };
}
function modelConversationErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
@@ -92,7 +138,8 @@ export function register(app, basePath = '') {
}
try {
const conversations = await db.list(req.user.id);
const rows = await db.list(req.user.id);
const conversations = rows.map(buildConversationResponse);
res.status(200).json({ conversations });
} catch (error) {
const status = modelConversationErrorStatus(error);
@@ -245,7 +292,7 @@ export function register(app, basePath = '') {
}
try {
const conversation = await db.get(req.user.id, parsedId.id);
const conversation = buildConversationResponse(await db.get(req.user.id, parsedId.id));
res.status(200).json({ conversation });
} catch (error) {
const status = modelConversationErrorStatus(error);

View File

@@ -22,6 +22,7 @@ function orderErrorStatus(error) {
if (error.message === 'Invalid lines') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Invalid seekMonths') return 400;
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
return 500;
}
@@ -58,10 +59,32 @@ export function register(app, basePath = '') {
* name: limit
* schema:
* type: integer
* default: 20
* default: 50
* minimum: 1
* maximum: 100
* description: Number of orders to return
* - in: query
* name: search
* schema:
* type: string
* description: Free-text search matching order number, external number, or customer name
* - in: query
* name: onlyUnpaid
* schema:
* type: boolean
* default: false
* description: When true, returns only unpaid orders (including overpaid orders)
* - in: query
* name: onlyUndelivered
* schema:
* type: boolean
* default: false
* description: When true, returns only undelivered orders
* - in: query
* name: seekMonths
* schema:
* type: integer
* description: Negative integer for cutoff months ago (e.g. -1 for orders older than 1 month)
* x-markdown: order/list.md
* responses:
* 200:
@@ -78,7 +101,7 @@ export function register(app, basePath = '') {
* $ref: '#/components/schemas/Order'
* total:
* type: integer
* description: Total number of unpaged orders
* description: Total number of filtered orders
* offset:
* type: integer
* description: Number of orders skipped
@@ -86,7 +109,7 @@ export function register(app, basePath = '') {
* type: integer
* description: Number of orders returned
* 400:
* description: Invalid offset, invalid limit, or no company selected
* description: Invalid query parameters, invalid offset, invalid limit, or no company selected
* content:
* application/json:
* schema:
@@ -200,7 +223,7 @@ export function register(app, basePath = '') {
const rawLimit = req.query.limit;
let offset = 0;
let limit = 20;
let limit = 50;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
@@ -216,8 +239,31 @@ export function register(app, basePath = '') {
}
}
const search = typeof req.query.search === 'string' && req.query.search.trim() !== ''
? req.query.search.trim()
: undefined;
const onlyUnpaid = req.query.onlyUnpaid === 'true';
const onlyUndelivered = req.query.onlyUndelivered === 'true';
let seekMonths = undefined;
if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') {
const parsedSeek = parseInt(req.query.seekMonths, 10);
if (!Number.isInteger(parsedSeek)) {
return res.status(400).json({ error: 'seekMonths must be an integer' });
}
seekMonths = parsedSeek;
}
try {
const result = await db.list(req.user.id, { offset, limit });
const result = await db.list(req.user.id, {
offset,
limit,
search,
onlyUnpaid: onlyUnpaid ? true : undefined,
onlyUndelivered: onlyUndelivered ? true : undefined,
seekMonths,
});
res.status(200).json(result);
} catch (error) {
const status = orderErrorStatus(error);

386
src/api/routes/shipment.js Normal file
View File

@@ -0,0 +1,386 @@
import * as db from '../../db/shipment.js';
import { emit } from '../sse.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parsePositiveInt(value, field) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function shipmentErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message?.endsWith(' not found')) return 404;
if (
error.message?.endsWith(' required') ||
error.message?.startsWith('Article (') ||
error.message?.includes('Invalid lines')
) {
return 400;
}
if (
error.message?.startsWith('Shipment amount exceeds') ||
error.message?.includes('cannot be deleted') ||
error.message?.includes('cannot be undone') ||
error.message?.includes('already shipped')
) {
return 409;
}
return 500;
}
function emitShipmentEvents(companyId) {
emit('order', { companyId });
emit('shipment', { companyId });
}
// #endregion
/**
* Registers shipment routes (shipments belong to an order).
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /order/{id}/shipment:
* get:
* tags: [Shipment]
* summary: List shipments of an order
* description: Lists all shipment manifests belonging to the specified order.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Order ID
* responses:
* 200:
* description: List of shipments
* content:
* application/json:
* schema:
* type: object
* properties:
* shipments:
* type: array
* items:
* $ref: '#/components/schemas/Shipment'
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Order not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /order/:id/shipment
app.get(`${basePath}/order/:id/shipment`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const shipments = await db.listByOrderId(req.user.id, parsedId.id);
res.status(200).json({ shipments });
} catch (error) {
const status = shipmentErrorStatus(error);
if (status === 500) console.error('List shipments failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /order/{id}/shipment:
* post:
* tags: [Shipment]
* summary: Create shipment for an order
* description: |
* Creates a new partial or full shipment manifest for the order.
* Validates that article positions belong to the order and cumulative shipped quantities
* do not exceed ordered quantities.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Order ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [lines]
* properties:
* date:
* type: string
* format: date
* externalNumber:
* type: string
* lines:
* type: array
* items:
* type: object
* required: [sku, amount]
* properties:
* sku:
* type: string
* amount:
* type: number
* name:
* type: string
* pu:
* type: number
* net:
* type: number
* rebate:
* type: number
* taxrate:
* type: number
* comment:
* type: string
* responses:
* 201:
* description: Created shipment
* content:
* application/json:
* schema:
* type: object
* properties:
* shipment:
* $ref: '#/components/schemas/Shipment'
* 400:
* description: Validation error or article not in order
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Order not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Cumulative shipment amount exceeds ordered amount
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /order/:id/shipment
app.post(`${basePath}/order/:id/shipment`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const shipment = await db.create(req.user.id, {
...(req.body ?? {}),
orderId: parsedId.id,
});
emitShipmentEvents(selected.companyId);
res.status(201).json({ shipment });
} catch (error) {
const status = shipmentErrorStatus(error);
if (status === 500) console.error('Create shipment failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /shipment/{id}:
* get:
* tags: [Shipment]
* summary: Get shipment by ID
* description: Retrieves shipment header, article positions, and attached packages.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Shipment ID
* responses:
* 200:
* description: Shipment details
* content:
* application/json:
* schema:
* type: object
* properties:
* shipment:
* $ref: '#/components/schemas/Shipment'
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Shipment not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /shipment/:id
app.get(`${basePath}/shipment/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const shipment = await db.getById(req.user.id, parsedId.id);
res.status(200).json({ shipment });
} catch (error) {
const status = shipmentErrorStatus(error);
if (status === 500) console.error('Get shipment failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /shipment/{id}:
* delete:
* tags: [Shipment]
* summary: Delete shipment
* description: Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Shipment ID
* responses:
* 204:
* description: Shipment deleted
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Shipment not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Shipped shipment cannot be deleted
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /shipment/:id
app.delete(`${basePath}/shipment/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
await db.remove(req.user.id, parsedId.id);
emitShipmentEvents(selected.companyId);
res.status(204).end();
} catch (error) {
const status = shipmentErrorStatus(error);
if (status === 500) console.error('Delete shipment failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -23,7 +23,12 @@ function shippingErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message?.endsWith(' not found')) return 404;
if (error.message?.endsWith(' required')) return 400;
if (
error.message?.endsWith(' required') ||
error.message?.includes('not a shipment')
) {
return 400;
}
if (
error.message?.includes('cannot be deleted') ||
error.message?.includes('cannot be undone') ||
@@ -227,6 +232,178 @@ export function register(app, basePath = '') {
// #endregion
/**
* @openapi
* /shipment/{id}/shipping:
* get:
* tags: [Shipping]
* summary: List packages of a shipment
* description: Lists all packages (shippings) belonging to the specified shipment manifest.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Shipment ID
* responses:
* 200:
* description: List of packages
* content:
* application/json:
* schema:
* type: object
* properties:
* shippings:
* type: array
* items:
* $ref: '#/components/schemas/Shipping'
* 400:
* description: Invalid ID or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Shipment not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /shipment/:id/shipping
app.get(`${basePath}/shipment/:id/shipping`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const shippings = await db.listByManifestId(req.user.id, parsedId.id);
res.status(200).json({ shippings });
} catch (error) {
const status = shippingErrorStatus(error);
if (status === 500) console.error('List shipment packages failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /shipment/{id}/shipping:
* post:
* tags: [Shipping]
* summary: Create package for a shipment
* description: |
* Creates a physical package (shipping) for the shipment manifest.
* Accepts carrier and parcel tracking code. If state is `shipped`,
* deducts stock for the shipment's lines via ledger movements.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Shipment ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [type, state]
* properties:
* type:
* type: string
* state:
* type: string
* trackingCode:
* type: string
* description: Carrier parcel tracking code
* carrier:
* type: string
* description: Carrier name (e.g. DHL, UPS, DPD)
* warehouseId:
* type: integer
* description: Optional warehouse ID for stock deduction when state is shipped
* responses:
* 201:
* description: Created package
* content:
* application/json:
* schema:
* type: object
* properties:
* shipping:
* $ref: '#/components/schemas/Shipping'
* 400:
* description: Validation error, manifest is not a shipment, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Shipment or warehouse not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /shipment/:id/shipping
app.post(`${basePath}/shipment/:id/shipping`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const shipping = await db.create(req.user.id, {
...(req.body ?? {}),
manifestId: parsedId.id,
});
if (String(shipping.state || '').trim().toLowerCase() === 'shipped') {
emitShippingEvents(selected.companyId);
}
res.status(201).json({ shipping });
} catch (error) {
const status = shippingErrorStatus(error);
if (status === 500) console.error('Create shipment package failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /shipping/{id}:
@@ -256,6 +433,12 @@ export function register(app, basePath = '') {
* type: string
* state:
* type: string
* trackingCode:
* type: string
* description: Carrier parcel tracking code
* carrier:
* type: string
* description: Carrier name (e.g. DHL, UPS, DPD)
* warehouseId:
* type: integer
* description: Warehouse ID for stock deduction when transitioning to shipped

View File

@@ -23,6 +23,7 @@ export default {
.split(',')
.map((s) => s.trim())
.filter(Boolean),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:4209',
googleClientId: process.env.GOOGLE_CLIENT_ID,
implicitCreate: process.env.IMPLICIT_CREATE === 'true',
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',

61
src/db/mcp.js Normal file
View File

@@ -0,0 +1,61 @@
import { getStore } from './store.js';
export async function registerClient(input) {
const store = await getStore();
return store.mcp.registerClient(input);
}
export async function getClient(clientId) {
const store = await getStore();
return (await store.mcp.getClient(clientId)) ?? null;
}
export async function listClients() {
const store = await getStore();
return store.mcp.listClients();
}
export async function deleteClient(clientId) {
const store = await getStore();
return store.mcp.deleteClient(clientId);
}
export async function createAuthCode(input) {
const store = await getStore();
return store.mcp.createAuthCode(input);
}
export async function getAuthCode(code) {
const store = await getStore();
return (await store.mcp.getAuthCode(code)) ?? null;
}
export async function consumeAuthCode(code) {
const store = await getStore();
return store.mcp.consumeAuthCode(code);
}
export async function createRefreshToken(input) {
const store = await getStore();
return store.mcp.createRefreshToken(input);
}
export async function getRefreshToken(refreshToken) {
const store = await getStore();
return (await store.mcp.getRefreshToken(refreshToken)) ?? null;
}
export async function revokeRefreshToken(refreshToken) {
const store = await getStore();
return store.mcp.revokeRefreshToken(refreshToken);
}
export async function listAuthorizations(googleAuthId) {
const store = await getStore();
return store.mcp.listAuthorizations(googleAuthId);
}
export async function revokeAuthorizations(googleAuthId, clientId) {
const store = await getStore();
return store.mcp.revokeAuthorizations(googleAuthId, clientId);
}

View File

@@ -1,35 +1,122 @@
import { getStore } from './store.js';
function round2(num) {
return Math.round((Number(num) || 0) * 100) / 100;
}
function calculateOrderGrossTotal(lines = []) {
let total = 0;
for (const line of lines) {
const amount = Number(line.amount || 0);
const net = Number(line.net || 0);
const rebate = Number(line.rebate || 0);
const taxrate = Number(line.taxrate || 0);
const netAfterRebate = net * (1 - rebate / 100);
const gross = netAfterRebate * (1 + taxrate / 100);
total += amount * gross;
}
return round2(total);
}
function calculatePaidTotal(payments = []) {
let total = 0;
for (const payment of payments) {
total += Number(payment.amount || 0);
}
return round2(total);
}
function calculateOrderedQuantity(lines = []) {
let qty = 0;
for (const line of lines) {
qty += Number(line.amount || 0);
}
return Math.round(qty * 1000) / 1000;
}
function calculateShippedQuantity(shipments = []) {
let qty = 0;
for (const shipment of shipments) {
for (const line of shipment.lines || []) {
qty += Number(line.amount || 0);
}
}
return Math.round(qty * 1000) / 1000;
}
async function enrichSingleOrder(store, googleAuthId, order) {
if (!order) return order;
const [shippings, payments] = await Promise.all([
const [shippings, payments, shipments] = await Promise.all([
store.shipping.listByManifestId(googleAuthId, order.id).catch(() => []),
store.payment.listByManifestId(googleAuthId, order.id).catch(() => []),
store.shipment.listByOrderId(googleAuthId, order.id).catch(() => []),
]);
const isPaid = Array.isArray(payments) && payments.length > 0;
const isDelivered = Array.isArray(shippings) && shippings.some((s) => s.state === 'shipped' || s.state === 'delivered');
const orderLines = Array.isArray(order.lines) ? order.lines : [];
const paymentsList = Array.isArray(payments) ? payments : [];
const shipmentsList = Array.isArray(shipments) ? shipments : [];
const shippingsList = Array.isArray(shippings) ? shippings : [];
const orderTotal = calculateOrderGrossTotal(orderLines);
const paidTotal = calculatePaidTotal(paymentsList);
const orderedQuantity = calculateOrderedQuantity(orderLines);
const shippedQuantity = calculateShippedQuantity(shipmentsList);
const allShippings = shippingsList.length > 0 ? shippingsList : shipmentsList.flatMap((s) => s.shippings || []);
const isPaid = orderTotal > 0 && Math.abs(paidTotal - orderTotal) < 0.01;
const isShipped = orderedQuantity > 0 && Math.abs(shippedQuantity - orderedQuantity) < 0.001;
const isDelivered = isShipped || allShippings.some((s) => s.state === 'shipped' || s.state === 'delivered');
return {
...order,
shipping: shippings[0] ?? null,
shippings: shippings ?? [],
payment: payments[0] ?? null,
payments: payments ?? [],
shipping: shippingsList[0] ?? (shipmentsList[0]?.shippings?.[0] ?? null),
shippings: allShippings,
payment: paymentsList[0] ?? null,
payments: paymentsList,
shipments: shipmentsList,
orderTotal,
paidTotal,
orderedQuantity,
shippedQuantity,
paid: isPaid,
shipped: isShipped,
delivered: isDelivered,
};
}
function formatEnrichedFromSummary(order) {
const grossTotal = Number(order.grossTotal || 0);
const paidTotal = Number(order.paidTotal || 0);
const orderedQuantity = Number(order.orderedQuantity || 0);
const shippedQuantity = Number(order.shippedQuantity || 0);
const isPaid = Boolean(order.isPaid);
const isShipped = Boolean(order.isShipped);
return {
...order,
shipping: null,
shippings: [],
payment: null,
payments: [],
shipments: [],
orderTotal: grossTotal,
paidTotal,
orderedQuantity,
shippedQuantity,
paid: isPaid,
shipped: isShipped,
delivered: isShipped,
};
}
async function enrichOrderResult(store, googleAuthId, result) {
if (!result) return result;
const enrich = (o) => (o?.grossTotal !== undefined ? formatEnrichedFromSummary(o) : enrichSingleOrder(store, googleAuthId, o));
if (Array.isArray(result)) {
return Promise.all(result.map((o) => enrichSingleOrder(store, googleAuthId, o)));
return Promise.all(result.map(enrich));
}
if (Array.isArray(result.orders)) {
const enrichedOrders = await Promise.all(
result.orders.map((o) => enrichSingleOrder(store, googleAuthId, o))
);
const enrichedOrders = await Promise.all(result.orders.map(enrich));
return {
...result,
orders: enrichedOrders,

21
src/db/shipment.js Normal file
View File

@@ -0,0 +1,21 @@
import { getStore } from './store.js';
export async function create(googleAuthId, data) {
const store = await getStore();
return store.shipment.create(googleAuthId, data);
}
export async function getById(googleAuthId, id) {
const store = await getStore();
return store.shipment.getById(googleAuthId, id);
}
export async function listByOrderId(googleAuthId, orderId) {
const store = await getStore();
return store.shipment.listByOrderId(googleAuthId, orderId);
}
export async function remove(googleAuthId, id) {
const store = await getStore();
return store.shipment.delete(googleAuthId, id);
}

View File

@@ -54,6 +54,7 @@ const STORE_NAMESPACES = [
'package',
'articlelist',
'optionslist',
'mcp',
];
/**

View File

@@ -62,6 +62,19 @@ export async function importOrders({
defaultCustomerGroupId = groups[0]?.id || null;
} catch {}
let defaultWarehouseId = null;
try {
const warehouses = await store.warehouse.list(googleAuthId);
if (warehouses && warehouses.length > 0) {
defaultWarehouseId = warehouses[0].id;
} else {
const wh = await store.warehouse.create(googleAuthId, { name: 'Standardlager' });
defaultWarehouseId = wh.id;
}
} catch (err) {
log.warn('Could not ensure warehouse:', err.message);
}
const entityMap = new Map();
try {
const entities = await store.entity.list(googleAuthId);
@@ -220,19 +233,37 @@ export async function importOrders({
// Handle Delivery / Shipping
const shippingType = cleanValue(m.shippingType);
if (shippingType) {
const isShipped = String(m.shipped ?? '').trim() === '1';
const shippingState = isShipped ? 'shipped' : 'open';
const isShipped = String(m.shipped ?? '').trim() === '1';
try {
await store.shipping.create(googleAuthId, {
manifestId: createdOrder.id,
type: shippingType,
state: shippingState,
});
shippingsImported++;
} catch (err) {
log.warn(`Failed to create shipping for order ${orderNumber}:`, err.message);
if (isShipped || shippingType) {
const shippableLines = lines
.filter((l) => l.sku)
.map((l) => ({ sku: l.sku, amount: l.amount }));
if (shippableLines.length > 0) {
try {
const shipment = await store.shipment.create(googleAuthId, {
orderId: createdOrder.id,
date: validDate,
lines: shippableLines,
});
const trackingCode = cleanValue(m.trackingCode ?? m.tracking_code ?? m.cTracking);
const carrier = cleanValue(m.carrier ?? m.cCarrier);
const shippingState = isShipped ? 'shipped' : 'draft';
await store.shipping.create(googleAuthId, {
manifestId: shipment.id,
type: shippingType || 'standard',
state: shippingState,
trackingCode: trackingCode || undefined,
carrier: carrier || undefined,
warehouseId: defaultWarehouseId || undefined,
});
shippingsImported++;
} catch (err) {
log.warn(`Failed to create shipment/shipping for order ${orderNumber}:`, err.message);
}
}
}
}

View File

@@ -28,6 +28,7 @@ import { createSearchArticlesTool } from '../tools/search-articles.js';
import { createEanChecksumTool } from '../tools/ean-checksum.js';
import { createNetGrossCalcTool } from '../tools/net-gross-calc.js';
import { createSetUserRouteTool } from '../tools/set-user-route.js';
import { createSubagentTool } from '../tools/subagent.js';
import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js';
// #region Constants
@@ -517,7 +518,7 @@ export class ConversationManager {
// #endregion
// #region LLM Run
// #region Run
/**
* Run the LLM over the stored transcript, stream the answer over SSE,
* persist reasoning, tool calls, and assistant messages, then return to `idle`.
@@ -757,6 +758,13 @@ export class ConversationManager {
this.broadcast('route', { route });
},
}),
createSubagentTool({
googleAuthId,
companyId: this.companyId,
conversationId: this.conversationId,
parentModel: model,
depth: 0,
}),
],
});

View File

@@ -98,6 +98,11 @@ export const TOOL_DESCRIPTIONS = {
set_user_route:
'Navigate user to a frontend route when asked to view/open an entity or page (see ROUTE MATRIX in prompt).',
subagent:
'Delegate a task to an isolated subagent. Pre-loads requested SKILL files into its ' +
'system prompt and returns a compact summary of tool executions alongside the final output. ' +
'Subagents can call all tools you can call except subagent, ask_user and set_user_route',
};
// #endregion
@@ -243,6 +248,14 @@ export const TOOL_PARAMETER_DESCRIPTIONS = {
set_user_route: {
route: 'Frontend route from Route Matrix (e.g. "/artikel/ART-10042", "/auftrag/1001").',
},
subagent: {
note: NOTE_DESC,
task: 'Specific instructions and goal for the subagent.',
skills: 'Array of SKILL names or slugs to inject directly into the subagent system prompt (e.g. ["api-article", "label"]).',
conversation_id: 'Existing subagent conversation ID to follow up on, or null to create a new one.',
max_iterations: 'Safety bound on tool loops (default 15, max 30).',
},
};
export const PATCH_PARAMETER_DESCRIPTIONS = TOOL_PARAMETER_DESCRIPTIONS.patch;

411
src/tools/subagent.js Normal file
View File

@@ -0,0 +1,411 @@
import fs from 'fs';
import path from 'path';
import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import * as db from '../db/modelconversation.js';
import { emit as emitCompanyEvent } from '../api/sse.js';
import { buildSystemPrompt } from '../services/prompts.js';
import { historyToLlmMessages } from '../services/conversationManager.js';
import { resolveOpenRouterKey, resolveModelReasoning } from './llm-key.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { SKILL_EXT, isSkillName, parseSkill } from './skill.js';
import { getApiRouteSkill } from './routes-skills.js';
import { prepareVfs } from './vfs.js';
import { createDashbackRequestTool } from './dashback-request.js';
import { createJavascriptExecuteTool } from './javascript.js';
import { createFindFilesTool } from './find-files.js';
import { createReadFileTool } from './read-file.js';
import { createPatchTool } from './patch.js';
import { createRmTool } from './rm.js';
import { createSearchImagesTool } from './search-images.js';
import { createAnalyzeImageTool } from './analyze-image.js';
import { createEditImageTool } from './edit-image.js';
import { createWebSearchTool } from './web-search.js';
import { createUrlGetTool } from './url-get.js';
import { createPdfReadTool } from './pdf-read.js';
import { createResetVfsTool } from './reset-vfs.js';
import { createApiSuggestionsOrIssuesTool } from './apisuggestionsorissues.js';
import { createSearchArticlesTool } from './search-articles.js';
import { createEanChecksumTool } from './ean-checksum.js';
import { createNetGrossCalcTool } from './net-gross-calc.js';
/**
* subagent — delegates a self-contained task to an isolated LLMOrbiter run that lives in
* its own persisted conversation (`modelconversation` / `modelmessage`).
*
* The subagent gets the parent's toolbelt minus `ask_user`, `subagent` and `set_user_route`
* (it cannot talk to the user, spawn further subagents or drive the UI). The parent only
* receives the final output plus a byte-level audit of the tool calls; the full transcript
* stays in the subagent's conversation.
*/
// #region Constants
const DEFAULT_MAX_ITERATIONS = 15;
const MAX_ITERATIONS_LIMIT = 30;
/** Subagents never spawn subagents: the parent runs at depth 0, its child at depth 1. */
const MAX_SUBAGENT_DEPTH = 1;
const SUBAGENT_PROVIDER = 'openrouter';
const SUBAGENT_PROMPT_NOTICE = [
'SUBAGENT MODE:',
'- You are a subagent working for a primary agent, not talking to the end user. You cannot ask the user questions.',
'- Complete the task with your tools, then finish with a concise, self-contained final answer: what was done, key findings, and any file paths or IDs the primary agent needs.',
'- Do not ask for confirmation; if the task is ambiguous, state your assumption and proceed.',
].join('\n');
// #endregion
// #region Skill Injection
/**
* Turns a skill name or slug into its flat SKILL file name: `api-article` → `api-article.SKILL`.
* @param {string} nameOrSlug
* @returns {string}
*/
function toSkillFileName(nameOrSlug) {
const trimmed = String(nameOrSlug ?? '').trim();
return trimmed.toLowerCase().endsWith(SKILL_EXT.toLowerCase()) ? trimmed : `${trimmed}${SKILL_EXT}`;
}
/**
* Finds a SKILL file by name: the company's VFS root first, then the virtual
* `api-<section>.SKILL` files generated from docs/api/routes.md.
* @param {string} vfsRoot
* @param {string} fileName
* @returns {string|null} raw SKILL content, or null when no such skill exists
*/
function readSkillContent(vfsRoot, fileName) {
const diskPath = path.join(vfsRoot, fileName);
if (fs.existsSync(diskPath) && fs.statSync(diskPath).isFile()) {
return fs.readFileSync(diskPath, 'utf8');
}
return getApiRouteSkill(fileName)?.content ?? null;
}
/**
* Resolves the requested skills and renders them as an `# INJECTED SKILLS` prompt block.
* Unknown skills are rejected as a whole so the primary agent can correct the call
* instead of delegating to a subagent that silently lacks its guidelines.
*
* @param {string} vfsRoot
* @param {string[]|null|undefined} skillNames
* @returns {string} empty string when no skills were requested
* @throws {ToolError} on malformed or unknown skill names
*/
function buildInjectedSkillsBlock(vfsRoot, skillNames) {
if (skillNames == null) return '';
if (!Array.isArray(skillNames)) throw new ToolError('"skills" must be an array of skill names or null.');
const fileNames = [...new Set(skillNames.map(toSkillFileName))];
const invalid = fileNames.filter((fileName) => !isSkillName(fileName));
if (invalid.length > 0) {
throw new ToolError(`Invalid skill name(s): ${invalid.join(', ')}. Skills are flat names like "api-article".`);
}
const sections = [];
const missing = [];
for (const fileName of fileNames) {
const content = readSkillContent(vfsRoot, fileName);
if (content == null) {
missing.push(fileName);
continue;
}
const { title, markdown } = parseSkill(content);
sections.push(`## ${title} (${fileName})\n\n${markdown}`);
}
if (missing.length > 0) {
throw new ToolError(
`Unknown skill(s): ${missing.join(', ')}. Call find_files({ skills: true }) to list available skills.`
);
}
return sections.length > 0 ? `# INJECTED SKILLS\n\n${sections.join('\n\n')}` : '';
}
// #endregion
// #region Conversation Guards
/**
* Rejects a follow-up when the target thread is not in a state where appending a user
* message is a legal turn: it must be idle, and its last conversational message must
* not already be a `user` message awaiting a response.
*
* @param {object} conversation
* @param {Array<{ role: string }>} history
* @throws {ToolError}
*/
function assertConversationAcceptsTask(conversation, history) {
if (conversation.state !== 'idle') {
throw new ToolError(`Subagent conversation ${conversation.id} is busy; wait for it to finish.`);
}
// `usage` and `reasoning` rows are bookkeeping, not conversational turns.
const lastTurn = [...history].reverse().find((m) => m.role !== 'usage' && m.role !== 'reasoning');
if (lastTurn?.role === 'user') {
throw new ToolError(
`Subagent conversation ${conversation.id} is already awaiting a response (last message is a user message).`
);
}
}
// #endregion
// #region Helpers
function stringifyToolOutput(result) {
return typeof result === 'string' ? result : JSON.stringify(result ?? '');
}
/**
* Builds the subagent toolbelt: the parent's tools without `ask_user`, `subagent` and
* `set_user_route`.
*/
function createSubagentTools({ googleAuthId, companyId, model }) {
return [
createDashbackRequestTool({ googleAuthId }),
createJavascriptExecuteTool({ googleAuthId }),
createFindFilesTool({ googleAuthId }),
createReadFileTool({ googleAuthId }),
createPatchTool({ googleAuthId }),
createRmTool({ googleAuthId }),
createSearchImagesTool({ googleAuthId }),
createAnalyzeImageTool({ googleAuthId, model }),
createEditImageTool({ googleAuthId, model }),
createWebSearchTool({ googleAuthId }),
createUrlGetTool({ googleAuthId }),
createPdfReadTool({ googleAuthId }),
createResetVfsTool({ googleAuthId }),
createApiSuggestionsOrIssuesTool({ googleAuthId, companyId }),
createSearchArticlesTool({ googleAuthId, companyId }),
createEanChecksumTool(),
createNetGrossCalcTool(),
];
}
// #endregion
// #region Factory
/**
* Creates the `subagent` tool.
*
* @param {{
* googleAuthId: number,
* companyId: number,
* conversationId: number,
* parentModel: string,
* depth?: number,
* }} ctx `conversationId` is the active parent conversation (target of the self-talk guard);
* `parentModel` is the parent's resolved model, inherited by the subagent.
*/
// MARK: createSubagentTool — guarded subagent execution loop
export function createSubagentTool({ googleAuthId, companyId, conversationId: parentConversationId, parentModel, depth = 0 }) {
return defineTool({
name: 'subagent',
description: TOOL_DESCRIPTIONS.subagent,
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['task'],
properties: {
task: { type: 'string', description: TOOL_PARAMETER_DESCRIPTIONS.subagent.task },
skills: {
type: ['array', 'null'],
items: { type: 'string' },
description: TOOL_PARAMETER_DESCRIPTIONS.subagent.skills,
},
conversation_id: {
type: ['integer', 'null'],
description: TOOL_PARAMETER_DESCRIPTIONS.subagent.conversation_id,
},
max_iterations: {
type: ['integer', 'null'],
description: TOOL_PARAMETER_DESCRIPTIONS.subagent.max_iterations,
},
note: { type: ['string', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.subagent.note },
},
},
invoke: async ({ task, skills = null, conversation_id: targetConversationId = null, max_iterations: maxIterations = null } = {}, { signal } = {}) => {
// MARK: validate input and guards before touching the database
if (depth >= MAX_SUBAGENT_DEPTH) {
throw new ToolError('Subagents cannot spawn further subagents.');
}
if (typeof task !== 'string' || !task.trim()) {
throw new ToolError('"task" must be a non-empty string.');
}
if (targetConversationId != null && targetConversationId === parentConversationId) {
throw new ToolError('Cannot target active parent conversation');
}
const iterationLimit = maxIterations != null ? Number(maxIterations) : DEFAULT_MAX_ITERATIONS;
if (!Number.isInteger(iterationLimit) || iterationLimit < 1) {
throw new ToolError(`"max_iterations" must be a positive integer (received ${maxIterations}).`);
}
const boundedIterations = Math.min(iterationLimit, MAX_ITERATIONS_LIMIT);
const vfsRoot = await prepareVfs({ googleAuthId });
const injectedSkills = buildInjectedSkillsBlock(vfsRoot, skills);
// MARK: create or resume the subagent conversation
let conversation;
let history = [];
if (targetConversationId != null) {
conversation = await db.get(googleAuthId, targetConversationId);
history = await db.listMessages(googleAuthId, targetConversationId);
if (conversation.started_by !== parentConversationId) {
throw new ToolError(`Conversation ${targetConversationId} is not a subagent conversation started by this conversation.`);
}
assertConversationAcceptsTask(conversation, history);
} else {
conversation = await db.create(googleAuthId, { provider: SUBAGENT_PROVIDER, model: parentModel, started_by: parentConversationId });
emitCompanyEvent('modelconversation', { companyId });
}
const subagentConversationId = conversation.id;
// Persist the task as a user turn (DB refuses unless idle), then mark busy.
await db.addMessage(googleAuthId, subagentConversationId, {
role: 'user',
message: JSON.stringify({ content: task.trim() }),
});
await db.setState(googleAuthId, subagentConversationId, 'busy');
emitCompanyEvent('modelconversation', { companyId });
const toolCalls = [];
const toolCallsById = new Map();
let streamedText = '';
let streamedReasoning = '';
const saveMessage = async (role, payload, label) => {
try {
await db.addMessage(googleAuthId, subagentConversationId, { role, message: JSON.stringify(payload) });
emitCompanyEvent('modelconversation', { companyId });
} catch (err) {
console.error(`[subagent ${subagentConversationId}] failed to save ${label}:`, err);
}
};
try {
const apiKey = await resolveOpenRouterKey(googleAuthId);
const reasoningEffort = await resolveModelReasoning(googleAuthId);
const fullHistory = await db.listMessages(googleAuthId, subagentConversationId);
const systemPrompt = [
await buildSystemPrompt(vfsRoot, googleAuthId),
SUBAGENT_PROMPT_NOTICE,
injectedSkills,
].filter(Boolean).join('\n\n');
const orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }), {
onReasoning: (reasoning) => {
streamedReasoning = reasoning;
},
onContent: (chunk) => {
streamedText += chunk;
},
onAssistantMessage: async ({ content, tool_calls, reasoning_details, reasoning }) => {
const plain = (reasoning || streamedReasoning.trim() || reasoning_details?.find((d) => d?.type === 'reasoning.summary')?.summary || '').trim();
const encrypted = reasoning_details?.find((d) => d?.type === 'reasoning.encrypted')?.data || '';
streamedReasoning = '';
if (plain || encrypted) {
await saveMessage('reasoning', { plain, encrypted }, 'reasoning message');
}
const trimmedContent = (content || streamedText).trim();
const hasToolCalls = Array.isArray(tool_calls) && tool_calls.length > 0;
if (trimmedContent || hasToolCalls) {
await saveMessage('assistant', {
content: trimmedContent,
...(hasToolCalls ? { tool_calls } : {}),
}, 'assistant message');
streamedText = '';
}
},
onToolCall: async ({ name, args, id, callItem }) => {
const rawArgs = callItem?.function?.arguments || (typeof args === 'string' ? args : JSON.stringify(args || {}));
const entry = { name, input_bytes: Buffer.byteLength(rawArgs), output_bytes: 0 };
toolCalls.push(entry);
toolCallsById.set(id || callItem?.id || `${name}#${toolCalls.length}`, entry);
console.log(`[subagent ${subagentConversationId}] tool call: ${name} args=${rawArgs}`);
},
onToolResult: async ({ name, result, id, callItem }) => {
const rawArgs = callItem?.function?.arguments || '';
const output = stringifyToolOutput(result);
const entry = toolCallsById.get(id || callItem?.id);
if (entry) entry.output_bytes = Buffer.byteLength(output);
await saveMessage('tool', {
name,
parameters: rawArgs,
content: output,
tool_call_id: id || callItem?.id || '',
}, 'tool result message');
},
onToolError: async ({ name, error, id, callItem }) => {
const rawArgs = callItem?.function?.arguments || '';
const output = `Tool error [${name}]: ${error}`;
const entry = toolCallsById.get(id || callItem?.id);
if (entry) entry.output_bytes = Buffer.byteLength(output);
await saveMessage('tool', {
name,
parameters: rawArgs,
content: output,
tool_call_id: id || callItem?.id || '',
}, 'tool error message');
},
onUsage: async (u) => {
if (!u) return;
await saveMessage('usage', {
input: u.input ?? 0,
cached: u.cached ?? 0,
output: u.output ?? 0,
...(u.cost != null ? { cost: u.cost } : {}),
}, 'usage message');
},
onLLMCall: (call) => {
console.log(
`[subagent ${subagentConversationId}] LLM call #${call.iteration}: ` +
`model=${call.model}, messages=${call.messageCount}, promptChars=${call.promptSize}`
);
},
});
const result = await orbiter.run({
messages: [
{ role: 'system', content: systemPrompt },
...historyToLlmMessages(fullHistory),
],
model: parentModel,
maxIterations: boundedIterations,
extraBody: reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {},
tools: createSubagentTools({ googleAuthId, companyId, model: parentModel }),
signal,
});
const output = (result.content ?? '').trim();
return {
conversation_id: subagentConversationId,
status: output ? 'completed' : 'empty',
output,
tool_calls: toolCalls,
iterations: result.iterations,
usage: {
input: result.usage?.input ?? 0,
output: result.usage?.output ?? 0,
cached: result.usage?.cached ?? 0,
},
};
} catch (err) {
if (err instanceof ToolError) throw err;
console.error(`[subagent ${subagentConversationId}] run failed:`, err);
throw new ToolError(`Subagent conversation ${subagentConversationId} failed: ${err.message}`);
} finally {
try {
await db.setState(googleAuthId, subagentConversationId, 'idle');
} catch (err) {
console.error(`[subagent ${subagentConversationId}] failed to reset state:`, err);
}
emitCompanyEvent('modelconversation', { companyId });
}
},
});
}
// #endregion