# jtlsrv Self-signed HTTPS debug server for **JTL-POS** pairing and sync, now split into a reusable library and a web admin UI. ## Project layout ``` . ├── lib/ # jtl-pos-server library │ ├── src/ │ │ ├── index.js # public exports (createStore, createJtlPosServer, createAdminServer, actions, seed) │ │ ├── store.js # in-memory state + reducer actions │ │ ├── actions.js # action creators for CRUD + pairing │ │ ├── seed.js # sample data │ │ ├── jtl-server.js # JTL-POS HTTPS protocol handler │ │ └── admin-server.js # Express admin API (/admin/api) │ └── package.json ├── web/ # Vite + React + MUI admin UI │ ├── src/ │ │ ├── main.jsx │ │ ├── App.jsx │ │ ├── api.js │ │ ├── components/CrudTable.jsx │ │ └── pages/*.jsx │ ├── package.json │ └── vite.config.js ├── certs/ # self-signed TLS certificate ├── server.js # thin wrapper: HTTPS POS server + admin API ├── test-client.js # probe real upstream or local server └── generate-cert.js # create self-signed TLS cert ``` ## Quick start ```bash npm install # install root deps (express, concurrently, uuid, local lib) cd web && npm install && cd .. npm run cert # once, before first start npm run dev # starts POS server + admin API + Vite dev server ``` - POS HTTPS endpoint: `https://localhost:8086` - Admin JSON API: `http://localhost:8087/admin/api` - Admin web UI: `http://localhost:5173` Environment variables: | Variable | Default | Purpose | |---|---|---| | `PORT` | `8086` | HTTPS POS server port | | `ADMIN_PORT` | `8087` | Admin API / static UI port | | `AUTH_TOKEN` | `9a2e3036ed9c47e389741d9dbb7590e9` | bearer token issued to paired devices | | `PAIRING_CODE` | `307018` | pre-registered 6-digit pairing code | ## Production build of the web UI ```bash npm run build # builds web/ into web/dist npm start # starts POS + admin API and serves web/dist ``` After `npm run build`, `server.js` serves the built admin SPA from `web/dist` on `ADMIN_PORT`. ## Using the web admin Open `http://localhost:5173` (dev) or `http://localhost:8087` (built). | Page | What you can do | |---|---| | **Dashboard** | View entity counts and paired devices | | **Pairing** | See/generate 6-digit pairing codes, list and revoke paired devices | | **Products** | CRUD products; mark products as composite and assign component products | | **Categories** | CRUD category tree via `pid` | | **Customers** | CRUD customers | | **Customer Groups** | CRUD customer groups | | **Deleted** | View deletion events and manually record new deletions | The admin API lives under `/admin/api` and mutates the same in-memory store as the POS sync endpoint. ## Programmatic use ```js const { createStore, createJtlPosServer, createAdminServer, actions } = require('./lib/src'); const store = createStore(); store.dispatch(actions.setPairingCode('307018')); const posHandler = createJtlPosServer(store, { authToken: '...' }); https.createServer({ key, cert }, posHandler).listen(8086); const { server: adminServer } = createAdminServer(store, { adminPort: 8087 }); ``` ## Protocol overview - **Transport:** HTTPS (TLS required) - **Base path:** `/api/v1/` - **Methods observed:** `GET` only (empty body) - **Content type:** `application/json; charset=utf-8` - **Numeric fields:** counts and IDs are returned as **strings**, not JSON numbers ### Request headers (JTL-POS client) | Header | Example | Notes | |---|---|---| | `accept` | `application/json` | | | `content-type` | `application/json` | sent even on GET | | `cache-control` | `no-cache` | | | `authorization` | `Bearer ` | required after pairing | | `version` | `1.0.11.14` | POS app version | | `system` | `JTL-POS` | | | `charset` | `utf-8` | | | `accept-encoding` | `gzip` | upstream may gzip responses | | `user-agent` | `Dalvik/2.1.0 …` | Android | ### Error responses Unknown paths return **404** with: ```json { "Message": "No HTTP resource was found that matches the request URI 'https://…'." } ``` Pairing errors (upstream, German) observed on `/api/v1/client`: | Situation | Message | |---|---| | Step 2 before step 1 | `Die erste Anfrage wurde noch nicht erfolgreich durchgeführt.` | | Step 1 repeated | `Die erste Anfrage wurde bereits erfolgreich durchgeführt.` | | Wrong code | `Der Authentifizierungscode ist falsch.` / `Keinen passenden Authentifizierungscode gefunden.` | Auth codes are **single-use** and expire quickly. --- ## Flow 1: Pairing (`/api/v1/client`) No `Authorization` header. Two-step GET handshake using a 6-digit code shown in JTL Wawi (or generated in the admin UI). ### Step 1 — first 4 digits ``` GET /api/v1/client?authCode=3070&name=001 ``` Response (fields vary; `authCode` is `null` on success): ```json { "authCode": null, "authToken": "9a2e3036ed9c47e389741d9dbb7590e9", "certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879", "certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3", "mandantId": "1", "mandantName": null, "mandantDatabase": null, "serverFingerprint": "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE", "name": null, "serverTimestamp": "2026-06-16 16:37:53" } ``` `serverFingerprint` is present in step 1; `mandantName` / `mandantDatabase` are null. ### Step 2 — full 6 digits ``` GET /api/v1/client?authCode=307018&name=001 ``` ```json { "authCode": "307018", "authToken": "9a2e3036ed9c47e389741d9dbb7590e9", "certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879", "certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3", "mandantId": "1", "mandantName": "eB-Standard", "mandantDatabase": "eazybusiness", "serverFingerprint": null, "name": null, "serverTimestamp": "2026-06-16 16:37:53" } ``` `authToken` is the bearer token used for all subsequent sync calls. `serverTimestamp` format: `YYYY-MM-DD HH:MM:SS`. Probe pairing: ```bash node test-client.js 307018 # runs step 1 + step 2 node test-client.js 3070 # step 1 only ``` --- ## Flow 2: Sync After pairing, the POS polls with `Authorization: Bearer `. ### `/api/v1/init` — sync manifest Returns how many entities still need syncing, based on the client's stored cursors. ``` GET /api/v1/init?mandantId=1 &lastChangedCategory=0 &lastChangedCustomer=0 &lastChangedCustomerGroup=0 &lastChangedProduct=0 &lastChangedConfigurationGroup=0 &lastChangedConfigurationItem=0 &lastChangedCompositeProduct=0 &lastChangedDeletedEntity=0 ``` Response (fresh mandant, all cursors at `0`): ```json { "version": "1.10.12.0", "product_count": "3", "category_count": "2", "customer_count": "3", "customerGroup_count": "1", "compositeProduct_count": "1", "configurationGroup_count": "0", "configurationItem_count": "0", "deletedEntity_count": "1", "max_orderId_count": "0" } ``` Each `*_count` is the number of records with `lastChanged` **greater than** the matching `lastChanged*` query parameter. Example: if the client has synced customer groups up to cursor `7323`, pass `lastChangedCustomerGroup=7323` and `customerGroup_count` becomes `"0"`. Typical poll sequence after initial sync: 1. `init` with all cursors at latest values → counts are `"0"` when fully synced 2. If a count is `> 0`, POS fetches the corresponding entity endpoint 3. POS updates its local cursor to the highest `lastChanged` received 4. Repeat Probe: ```bash node test-client.js init ``` --- ### Incremental sync pattern Entity list endpoints use the same cursor model: - Query param: `lastChanged=` (client's last known value) - Response: JSON **array** of records where `record.lastChanged > cursor` - Empty array `[]` means the client is up to date - After sync, the client stores `max(lastChanged)` and sends that on the next poll --- ### `/api/v1/customergroup` ``` GET /api/v1/customergroup?mandantId=1&lastChangedCustomerGroup=0 ``` ```json [ { "customerGroupId": "1", "name": "Endkunden", "standard": "1", "discountPercent": "0.00", "lastChanged": "7323" } ] ``` | `lastChangedCustomerGroup` | Result | |---|---| | `0` | all groups changed since beginning | | `7323` | `[]` (client has this revision) | | `7324` | `[]` | Probe: ```bash node test-client.js customergroup node test-client.js customergroup 7323 ``` --- ### `/api/v1/deletedentity` ``` GET /api/v1/deletedentity?mandantId=1&limit=200&lastChangedDeletedEntity=0 ``` ```json [ { "entityId": "2", "entityType": "6", "lastChanged": "8281" } ] ``` | Field | Meaning | |---|---| | `entityId` | ID of the deleted record | | `entityType` | numeric type code (`6` observed for a customer group) | | `lastChanged` | revision cursor for this deletion event | | `lastChangedDeletedEntity` | Result | |---|---| | `0` | pending deletions | | `8281` | `[]` (client has processed this deletion) | `limit` caps how many records are returned per request (POS uses `200`). Probe: ```bash node test-client.js deletedentity node test-client.js deletedentity 8281 ``` --- ### `/api/v1/category` ``` GET /api/v1/category?mandantId=1&limit=20&lastChangedCategory=0 ``` ```json [ { "_id": "1", "imghash": null, "imgsrc": null, "name": "Haupt", "pid": "0", "discounts": [], "sort": "0", "updated_at": "2026-06-16 22:42:49", "created_at": "2026-06-16 22:42:49", "lastChanged": "10252" } ] ``` `pid` is the parent category ID (`"0"` = root). The server also serves a sample child category `Getränke` (`_id: "2"`, `pid: "1"`). Probe: `node test-client.js category ` --- ### `/api/v1/product` ``` GET /api/v1/product?mandantId=1&limit=20&lastChangedProduct=0 ``` Large product objects; key fields: | Field | Notes | |---|---| | `_id` | product ID | | `name`, `sku`, `price`, `tax_rate` | basics | | `categories_id` / `categories` | category linkage | | `isCompositeProduct` | `"1"` if this product is a bundle/set | | `prices` | per customer group / customer overrides | | `lastChanged` | sync cursor | Upstream example products: `a1` (normal), `a2` (`isCompositeProduct: "1"`). The debug server also adds `Cola 0,5l`. Composite **definitions** (which components make up a bundle) are **not** embedded in the product object — see `/api/v1/productcomposite`. Probe: `node test-client.js product ` --- ### `/api/v1/customer` ``` GET /api/v1/customer?mandantId=1&limit=20&lastChangedCustomer=0 ``` ```json [ { "id": "1", "customerNumber": "0", "firstname": "", "lastname": "kjhkjh", "company": "kjhkjh", "city": "kjhkjhkjh", "country": "Deutschland", "customerGroupId": "1", "discount": "0.00", "lastChanged": "13246", "debtorNumber": "0" } ] ``` The debug server also includes sample customers `Muster GmbH` and `Max Mustermann`. Probe: `node test-client.js customer ` --- ### `/api/v1/productcomposite` — composite product components There is **no** `/api/v1/compositeproduct` endpoint (returns 404). Composite bundles use this path instead: ``` GET /api/v1/productcomposite?mandantId=1&limit=20&lastChangedCompositeProduct=0 ``` ```json [ { "productId": "2", "productIdComponent": "1", "quantity": "1.00", "lastChanged": "13227" } ] ``` | Field | Meaning | |---|---| | `productId` | the composite/bundle product (`a2`, `isCompositeProduct: "1"`) | | `productIdComponent` | component product included in the bundle | | `quantity` | how many units of the component | `init.compositeProduct_count` tracks pending rows here, separate from `product_count`. Probe: `node test-client.js productcomposite ` --- ## Observed sync order After pairing, the POS roughly follows: ``` init → customergroup → init → category → init → product → init → productcomposite → init → customer → init → deletedentity → init → … ``` `init` is called before and after each entity fetch. Counts in `init` drive which endpoint the POS calls next. --- ## Implemented in server | Endpoint | Sample data | |---|---| | `/api/v1/client` | pairing flow | | `/api/v1/init` | dynamic counts from cursors | | `/api/v1/customergroup` | 1 group (Endkunden) | | `/api/v1/category` | 2 categories (Haupt + Getränke) | | `/api/v1/product` | 3 products (a1, a2 composite, Cola) | | `/api/v1/productcomposite` | 1 bundle link (a2 → a1) | | `/api/v1/customer` | 3 customers | | `/api/v1/deletedentity` | 1 deletion event | --- ## Admin API endpoints All prefixed with `/admin/api`. | Method | Path | Description | |---|---|---| | GET | `/init` | counts + full state snapshot | | GET | `/state` | current in-memory state | | GET | `/categories` | list categories | | POST | `/categories` | create category | | PATCH | `/categories/:id` | update category | | DELETE | `/categories/:id` | delete category | | GET | `/products` | list products | | POST | `/products` | create product | | PATCH | `/products/:id` | update product | | DELETE | `/products/:id` | delete product | | GET | `/customers` | list customers | | POST | `/customers` | create customer | | PATCH | `/customers/:id` | update customer | | DELETE | `/customers/:id` | delete customer | | GET | `/customer-groups` | list customer groups | | POST | `/customer-groups` | create group | | PATCH | `/customer-groups/:id` | update group | | DELETE | `/customer-groups/:id` | delete group | | GET | `/product-composites` | list composite links | | POST | `/product-composites` | add or update composite link | | DELETE | `/product-composites/:parent/:component` | remove link | | GET | `/pairing` | pending codes + paired devices | | POST | `/pairing` | generate a new 6-digit code (`{ name }`) | | POST | `/pairing/revoke` | revoke a pending code | | DELETE | `/devices/:token` | revoke a paired device | | GET | `/deleted` | list deletion events | | POST | `/deleted` | record a deletion (`{ entityId, entityType }`) | --- ## Endpoints not yet implemented | `init` count field | Endpoint | Notes | |---|---|---| | `configurationGroup_count` | `/api/v1/configurationgroup` | returns `[]` upstream (no data yet) | | `configurationItem_count` | `/api/v1/configurationitem` | returns `[]` upstream (no data yet) | --- ## Development workflow 1. Run `npm run dev` to start both servers and the web UI. 2. Point POS at `https://:8086` and pair with code `307018` (or generate a new one in the web UI). 3. Manage sample data through the web admin; counts in `/api/v1/init` update automatically. 4. If the POS requests an unknown path, replay it against the upstream server with `test-client.js`, then extend `lib/src/jtl-server.js` or the relevant action in `lib/src/store.js`. 5. For list endpoints, ensure records are filtered by `lastChanged > cursor` and honors `limit`. 6. Keep IDs and counts as strings to match upstream.