This commit is contained in:
seb
2026-10-30 18:09:31 +01:00
parent 5fac537268
commit 8fece9d916
13 changed files with 642 additions and 3 deletions

View File

@@ -37,6 +37,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/account.get.md`](routes/account.get.md) | `GET /account` | | [`routes/account.get.md`](routes/account.get.md) | `GET /account` |
| [`routes/account.post.md`](routes/account.post.md) | `POST /account` | | [`routes/account.post.md`](routes/account.post.md) | `POST /account` |
| [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` | | [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` |
| [`routes/agent/events.md`](routes/agent/events.md) | `GET /agent/events` |
| [`routes/agent/pair.get.md`](routes/agent/pair.get.md) | `GET /agent/pair` |
| [`routes/agent/pair.post.md`](routes/agent/pair.post.md) | `POST /agent/pair` |
| [`routes/agent/state.md`](routes/agent/state.md) | `POST /agent/state` |
| [`routes/article/create.md`](routes/article/create.md) | `POST /article` | | [`routes/article/create.md`](routes/article/create.md) | `POST /article` |
| [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` | | [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` |
| [`routes/article/history-list.md`](routes/article/history-list.md) | `GET /article/history` | | [`routes/article/history-list.md`](routes/article/history-list.md) | `GET /article/history` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 288 API endpoints across all modules. Overview of all 292 API endpoints across all modules.
### Notation ### Notation
@@ -73,6 +73,21 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
### Section: /agent
#### Printer agent SSE stream — `GET /agent/events`
- Params: `query: token?: str, agentversion?: str`
- Returns: `200 SSE`
#### Pair printer agent via query parameters — `GET /agent/pair`
- Params: `query: authCode?: str, pairingpin?: str, serial?: str, model?: str, version?: str`
- Returns: `200`
#### Pair printer agent — `POST /agent/pair`
- Params: `body: { pairingpin: str, clientserial?: str, clientmodel?: str, agentversion?: str }`
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
#### Post agent state — `POST /agent/state`
- Params: `body: { state?: any }`
- Returns: `200 { success: bool, agent: T8 }`
### Section: /article ### Section: /article
#### Create article in category — `POST /article` #### Create article in category — `POST /article`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 208 API endpoints across all modules. Overview of all 212 API endpoints across all modules.
### Notation ### Notation
@@ -42,6 +42,21 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Routes ### Routes
### Section: /agent
#### Printer agent SSE stream — `GET /agent/events`
- Params: `query: token?: str, agentversion?: str`
- Returns: `200 SSE`
#### Pair printer agent via query parameters — `GET /agent/pair`
- Params: `query: authCode?: str, pairingpin?: str, serial?: str, model?: str, version?: str`
- Returns: `200`
#### Pair printer agent — `POST /agent/pair`
- Params: `body: { pairingpin: str, clientserial?: str, clientmodel?: str, agentversion?: str }`
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
#### Post agent state — `POST /agent/state`
- Params: `body: { state?: any }`
- Returns: `200 { success: bool, agent: T5 }`
### Section: /article ### Section: /article
#### Create article in category — `POST /article` #### Create article in category — `POST /article`

View File

@@ -0,0 +1,30 @@
# `GET /agent/events`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Printer agent SSE stream
Authorized Server-Sent Events stream for connected printer agents.
Maintains a persistent connection with regular heartbeats (`: heartbeat\n\n`) and updates `lastseen_at` and `agentversion`.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `token` | no | string | Agent auth token if not provided via Authorization header |
| `agentversion` | no | string | Current agent version |
## Response `200`
Event stream established
## Errors
| Status | Description |
|--------|-------------|
| 401 | Unauthorized |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,31 @@
# `GET /agent/pair`
Public. No cookie required.
Pair printer agent via query parameters
Alternative GET endpoint for devices that pair via query parameters.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `authCode` | no | string | Pairing PIN (or pairingpin) |
| `pairingpin` | no | string | |
| `serial` | no | string | |
| `model` | no | string | |
| `version` | no | string | |
## Response `200`
Pairing successful
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing or invalid pairing PIN |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,55 @@
# `POST /agent/pair`
Public. No cookie required.
Pair printer agent
Public, unauthorized endpoint used by the printer agent to complete pairing using a 6-digit PIN.
Updates `clientserial`, `clientmodel`, and `agentversion`, clears the pairing PIN, and returns a device authentication token.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `pairingpin` | yes | string | |
| `clientserial` | no | string | |
| `clientmodel` | no | string | |
| `agentversion` | no | string | |
```json
{
"pairingpin": "123456",
"clientserial": "SN-987654321",
"clientmodel": "Epson TM-T88VI",
"agentversion": "1.0.0"
}
```
## Response `200`
Pairing successful
```json
{
"id": 1,
"name": "…",
"companyId": 1,
"token": "…",
"pairedAt": "…",
"clientserial": "…",
"clientmodel": "…",
"agentversion": "…",
"sort": 1
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing or invalid pairing PIN — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,55 @@
# `POST /agent/state`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Post agent state
Authorized endpoint for the printer agent to report its operational status/telemetry.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `state` | no | | Status text or status object |
```json
{
"state": "…"
}
```
## Response `200`
State recorded
```json
{
"success": true,
"agent": {
"id": 1,
"companyId": 1,
"name": "…",
"token": "…",
"pairingpin": "…",
"pairedAt": "…",
"clientserial": "…",
"clientmodel": "…",
"clientstate": "…",
"agentversion": "…",
"lastseenAt": "…",
"updatedAt": "…",
"sort": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 401 | Unauthorized |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

315
src/api/agent/index.js Normal file
View File

@@ -0,0 +1,315 @@
import express from 'express';
import crypto from 'node:crypto';
import * as db from '../../db/printeragent.js';
import { agentAuthMiddleware, extractAgentVersion } from '../middleware/agentAuth.js';
import { emit } from '../sse.js';
export const agentRouter = express.Router();
/**
* Helper to extract pairing parameters from body or query.
*/
function extractPairParams(req) {
const rawPin = req.body?.pairingpin ||
req.body?.authCode ||
req.body?.pin ||
req.body?.code ||
req.query?.pairingpin ||
req.query?.authCode ||
req.query?.pin ||
req.query?.code ||
'';
const pairingpin = String(rawPin).trim();
const rawSerial = req.body?.clientserial ||
req.body?.serial ||
req.body?.name ||
req.query?.clientserial ||
req.query?.serial ||
req.query?.name ||
null;
const clientserial = rawSerial ? String(rawSerial).trim() : null;
const rawModel = req.body?.clientmodel ||
req.body?.model ||
req.query?.clientmodel ||
req.query?.model ||
null;
const clientmodel = rawModel ? String(rawModel).trim() : null;
const agentversion = extractAgentVersion(req);
return { pairingpin, clientserial, clientmodel, agentversion };
}
async function handlePair(req, res) {
const { pairingpin, clientserial, clientmodel, agentversion } = extractPairParams(req);
if (!pairingpin) {
return res.status(400).json({ error: 'pairingpin is required' });
}
try {
const agent = await db.findByPairingpin(pairingpin);
if (!agent) {
return res.status(400).json({ error: 'Invalid pairing PIN' });
}
const token = crypto.randomBytes(32).toString('hex');
const pairedAt = new Date().toISOString();
const paired = await db.pair(agent.id, {
token,
clientserial,
clientmodel,
agentversion,
pairedAt,
});
if (paired.companyId) {
emit('printeragent', { companyId: paired.companyId });
}
return res.status(200).json({
id: paired.id,
name: paired.name,
companyId: paired.companyId,
token,
pairedAt: paired.pairedAt,
clientserial: paired.clientserial,
clientmodel: paired.clientmodel,
agentversion: paired.agentversion,
sort: paired.sort,
});
} catch (err) {
console.error('Agent pairing failed:', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
/**
* @openapi
* /agent/pair:
* post:
* tags: [Agent]
* summary: Pair printer agent
* description: |
* Public, unauthorized endpoint used by the printer agent to complete pairing using a 6-digit PIN.
* Updates `clientserial`, `clientmodel`, and `agentversion`, clears the pairing PIN, and returns a device authentication token.
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [pairingpin]
* properties:
* pairingpin:
* type: string
* example: "123456"
* clientserial:
* type: string
* example: "SN-987654321"
* clientmodel:
* type: string
* example: "Epson TM-T88VI"
* agentversion:
* type: string
* example: "1.0.0"
* responses:
* 200:
* description: Pairing successful
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: integer
* name:
* type: string
* companyId:
* type: integer
* token:
* type: string
* pairedAt:
* type: string
* clientserial:
* type: string
* nullable: true
* clientmodel:
* type: string
* nullable: true
* agentversion:
* type: string
* nullable: true
* sort:
* type: integer
* 400:
* description: Missing or invalid pairing PIN
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* get:
* tags: [Agent]
* summary: Pair printer agent via query parameters
* description: Alternative GET endpoint for devices that pair via query parameters.
* security: []
* parameters:
* - in: query
* name: authCode
* schema:
* type: string
* description: Pairing PIN (or pairingpin)
* - in: query
* name: pairingpin
* schema:
* type: string
* - in: query
* name: serial
* schema:
* type: string
* - in: query
* name: model
* schema:
* type: string
* - in: query
* name: version
* schema:
* type: string
* responses:
* 200:
* description: Pairing successful
* 400:
* description: Missing or invalid pairing PIN
*/
agentRouter.post('/pair', handlePair);
agentRouter.get('/pair', handlePair);
/**
* @openapi
* /agent/events:
* get:
* tags: [Agent]
* summary: Printer agent SSE stream
* description: |
* Authorized Server-Sent Events stream for connected printer agents.
* Maintains a persistent connection with regular heartbeats (`: heartbeat\n\n`) and updates `lastseen_at` and `agentversion`.
* security:
* - bearerAuth: []
* - apiKeyAuth: []
* parameters:
* - in: query
* name: token
* schema:
* type: string
* description: Agent auth token if not provided via Authorization header
* - in: query
* name: agentversion
* schema:
* type: string
* description: Current agent version
* responses:
* 200:
* description: Event stream established
* content:
* text/event-stream:
* schema:
* type: string
* 401:
* description: Unauthorized
*/
agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
const version = extractAgentVersion(req);
if (version) {
db.touchLastseen(req.agent.id, version).catch(() => {});
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
res.write(`event: connected\ndata: ${JSON.stringify({ agentId: req.agent.id, name: req.agent.name })}\n\n`);
const keepAlive = setInterval(() => {
if (res.writableEnded) return;
res.write(': heartbeat\n\n');
db.touchLastseen(req.agent.id).catch(() => {});
}, 15000);
const cleanup = () => {
clearInterval(keepAlive);
};
req.on('close', cleanup);
res.on('close', cleanup);
});
/**
* @openapi
* /agent/state:
* post:
* tags: [Agent]
* summary: Post agent state
* description: |
* Authorized endpoint for the printer agent to report its operational status/telemetry.
* security:
* - bearerAuth: []
* - apiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* state:
* description: Status text or status object
* responses:
* 200:
* description: State recorded
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* agent:
* $ref: '#/components/schemas/Printeragent'
* 401:
* description: Unauthorized
*/
async function handleState(req, res) {
try {
const rawState = req.body?.state !== undefined ? req.body.state : (req.body?.clientstate !== undefined ? req.body.clientstate : req.body);
const stateStr = rawState == null ? null : (typeof rawState === 'string' ? rawState : JSON.stringify(rawState));
const updated = await db.updateState(req.agent.id, stateStr);
const version = extractAgentVersion(req);
if (version) {
await db.touchLastseen(req.agent.id, version);
}
if (updated.companyId) {
emit('printeragent', { companyId: updated.companyId });
}
return res.status(200).json({
success: true,
agent: updated,
});
} catch (err) {
console.error('Agent post state failed:', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
agentRouter.post('/state', agentAuthMiddleware, handleState);
agentRouter.put('/state', agentAuthMiddleware, handleState);

View File

@@ -18,6 +18,7 @@ import { shutdownJavascriptSandboxWorkers } from '../services/javascriptSandbox.
import { getOrCreateCert } from '../util/cert.js'; import { getOrCreateCert } from '../util/cert.js';
import { createLogger } from '../util/logger.js'; import { createLogger } from '../util/logger.js';
import v1Router from '../v1/index.js'; import v1Router from '../v1/index.js';
import { agentRouter } from './agent/index.js';
import { startBackendMonitor, stopBackendMonitor, isDevMode } from '../backendmonitor/server.js'; import { startBackendMonitor, stopBackendMonitor, isDevMode } from '../backendmonitor/server.js';
import { startBackupService, stopBackupService } from '../services/backupService.js'; import { startBackupService, stopBackupService } from '../services/backupService.js';
import { startHealthMonitorService, stopHealthMonitorService } from '../services/healthMonitorService.js'; import { startHealthMonitorService, stopHealthMonitorService } from '../services/healthMonitorService.js';
@@ -100,6 +101,20 @@ function corsMiddleware(req, res, next) {
return next(); return next();
} }
if (req.path.startsWith('/api/agent')) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
const reqHeaders = req.headers['access-control-request-headers'];
res.setHeader(
'Access-Control-Allow-Headers',
reqHeaders || 'Content-Type, Authorization, x-agent-token, x-auth-token, authtoken, auth-token, token, x-token, agentversion, clientversion, version'
);
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('/v1')) { if (req.path.startsWith('/v1')) {
if (origin) { if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Origin', origin);
@@ -144,7 +159,7 @@ export async function buildApiApp() {
app.use((req, res, next) => { app.use((req, res, next) => {
// Do not attach no-store to SSE or public endpoints // Do not attach no-store to SSE or public endpoints
if (req.path !== '/api/events' && !req.path.startsWith('/api/public') && !req.path.startsWith('/public')) { if (req.path !== '/api/events' && !req.path.endsWith('/events') && !req.path.startsWith('/api/public') && !req.path.startsWith('/public')) {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
} }
next(); next();
@@ -164,6 +179,7 @@ export async function buildApiApp() {
} }
app.routeCount = await registerRoutes(app, '/api'); app.routeCount = await registerRoutes(app, '/api');
app.use('/api/agent', agentRouter);
app.use('/api/v1', v1Router); app.use('/api/v1', v1Router);
app.use((_req, res) => { app.use((_req, res) => {

View File

@@ -0,0 +1,72 @@
import * as db from '../../db/printeragent.js';
export function extractAgentToken(req) {
const authHeader = req.headers.authorization;
if (typeof authHeader === 'string' && authHeader.trim()) {
const trimmed = authHeader.trim();
if (/^Bearer\s+/i.test(trimmed)) {
return trimmed.replace(/^Bearer\s+/i, '').trim();
}
return trimmed;
}
for (const key of ['x-agent-token', 'x-auth-token', 'authtoken', 'auth-token', 'token', 'x-token']) {
const val = req.headers[key];
if (typeof val === 'string' && val.trim()) {
return val.trim();
}
}
const queryToken = req.query?.authToken || req.query?.token;
if (typeof queryToken === 'string' && queryToken.trim()) {
return queryToken.trim();
}
return '';
}
export function extractAgentVersion(req) {
const raw = req.headers['agentversion'] ||
req.headers['agent-version'] ||
req.headers['clientversion'] ||
req.headers['version'] ||
req.query?.agentversion ||
req.query?.version ||
req.body?.agentversion ||
req.body?.version ||
null;
return raw ? String(raw).trim() : null;
}
/**
* Authenticates the printer agent via token and attaches `req.agent`.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export async function agentAuthMiddleware(req, res, next) {
const token = extractAgentToken(req);
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const agent = await db.findByToken(token);
if (!agent) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.agent = agent;
const version = extractAgentVersion(req);
db.touchLastseen(agent.id, version).catch((err) => {
console.error('Failed to touch lastseen for printeragent:', err);
});
next();
} catch (err) {
console.error('Agent auth error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
}

View File

@@ -63,6 +63,10 @@ export const authMiddleware = async (req, res, next) => {
return next(); return next();
} }
if (req.path.startsWith('/api/agent')) {
return next();
}
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) { if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
return next(); return next();
} }

View File

@@ -44,6 +44,7 @@ export const openApiOptions = {
{ name: 'Posterminal', description: 'Company-scoped POS terminals' }, { name: 'Posterminal', description: 'Company-scoped POS terminals' },
{ name: 'Printer', description: 'Company-scoped receipt and label printers' }, { name: 'Printer', description: 'Company-scoped receipt and label printers' },
{ name: 'Printeragent', description: 'Company-scoped printer agents (create, rename, delete)' }, { name: 'Printeragent', description: 'Company-scoped printer agents (create, rename, delete)' },
{ name: 'Agent', description: 'Printer agent device endpoints (pairing, SSE events, state)' },
{ name: 'Ledger', description: 'Append-only ledger transactions' }, { name: 'Ledger', description: 'Append-only ledger transactions' },
{ name: 'Stock', description: 'Manual stock additions and removals' }, { name: 'Stock', description: 'Manual stock additions and removals' },
{ name: 'Warehouse', description: 'Company-scoped warehouses' }, { name: 'Warehouse', description: 'Company-scoped warehouses' },
@@ -56,6 +57,7 @@ export const openApiOptions = {
apis: [ apis: [
path.join(root, 'src/api/openapi.js'), path.join(root, 'src/api/openapi.js'),
path.join(root, 'src/api/routes/**/*.js'), path.join(root, 'src/api/routes/**/*.js'),
path.join(root, 'src/api/agent/**/*.js'),
path.join(root, 'src/v1/**/*.js'), path.join(root, 'src/v1/**/*.js'),
], ],
}; };

View File

@@ -19,3 +19,28 @@ export async function remove(googleAuthId, id) {
const store = await getStore(); const store = await getStore();
return store.printeragent.delete(googleAuthId, id); return store.printeragent.delete(googleAuthId, id);
} }
export async function findByPairingpin(pairingpin) {
const store = await getStore();
return store.printeragent.findByPairingpin(pairingpin);
}
export async function findByToken(token) {
const store = await getStore();
return store.printeragent.findByToken(token);
}
export async function pair(id, data) {
const store = await getStore();
return store.printeragent.pair(id, data);
}
export async function updateState(id, clientstate) {
const store = await getStore();
return store.printeragent.updateState(id, clientstate);
}
export async function touchLastseen(id, agentversion) {
const store = await getStore();
return store.printeragent.touchLastseen(id, agentversion);
}