From 8fece9d91665248ccc19d044ab420f66327bb8e6 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 30 Oct 2026 18:09:31 +0100 Subject: [PATCH] u --- docs/api/README.md | 4 + docs/api/routes-all.md | 17 +- docs/api/routes.md | 17 +- docs/api/routes/agent/events.md | 30 +++ docs/api/routes/agent/pair.get.md | 31 +++ docs/api/routes/agent/pair.post.md | 55 +++++ docs/api/routes/agent/state.md | 55 +++++ src/api/agent/index.js | 315 +++++++++++++++++++++++++++++ src/api/index.js | 18 +- src/api/middleware/agentAuth.js | 72 +++++++ src/api/middleware/auth.js | 4 + src/api/openapiSpec.js | 2 + src/db/printeragent.js | 25 +++ 13 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 docs/api/routes/agent/events.md create mode 100644 docs/api/routes/agent/pair.get.md create mode 100644 docs/api/routes/agent/pair.post.md create mode 100644 docs/api/routes/agent/state.md create mode 100644 src/api/agent/index.js create mode 100644 src/api/middleware/agentAuth.js diff --git a/docs/api/README.md b/docs/api/README.md index 4cec150..d34f894 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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.post.md`](routes/account.post.md) | `POST /account` | | [`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/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` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 2d312fd..228e9b9 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 288 API endpoints across all modules. +Overview of all 292 API endpoints across all modules. ### Notation @@ -73,6 +73,21 @@ _none_ - Params: `id: int` - 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 #### Create article in category — `POST /article` diff --git a/docs/api/routes.md b/docs/api/routes.md index e3aa1a5..cb46ba4 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 208 API endpoints across all modules. +Overview of all 212 API endpoints across all modules. ### Notation @@ -42,6 +42,21 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### 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 #### Create article in category — `POST /article` diff --git a/docs/api/routes/agent/events.md b/docs/api/routes/agent/events.md new file mode 100644 index 0000000..685cc08 --- /dev/null +++ b/docs/api/routes/agent/events.md @@ -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`._ diff --git a/docs/api/routes/agent/pair.get.md b/docs/api/routes/agent/pair.get.md new file mode 100644 index 0000000..8a1fd8b --- /dev/null +++ b/docs/api/routes/agent/pair.get.md @@ -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`._ diff --git a/docs/api/routes/agent/pair.post.md b/docs/api/routes/agent/pair.post.md new file mode 100644 index 0000000..8b691d6 --- /dev/null +++ b/docs/api/routes/agent/pair.post.md @@ -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`._ diff --git a/docs/api/routes/agent/state.md b/docs/api/routes/agent/state.md new file mode 100644 index 0000000..3d86425 --- /dev/null +++ b/docs/api/routes/agent/state.md @@ -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`._ diff --git a/src/api/agent/index.js b/src/api/agent/index.js new file mode 100644 index 0000000..ad6eeab --- /dev/null +++ b/src/api/agent/index.js @@ -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); + diff --git a/src/api/index.js b/src/api/index.js index 7f8d8af..05c1b83 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -18,6 +18,7 @@ import { shutdownJavascriptSandboxWorkers } from '../services/javascriptSandbox. import { getOrCreateCert } from '../util/cert.js'; import { createLogger } from '../util/logger.js'; import v1Router from '../v1/index.js'; +import { agentRouter } from './agent/index.js'; import { startBackendMonitor, stopBackendMonitor, isDevMode } from '../backendmonitor/server.js'; import { startBackupService, stopBackupService } from '../services/backupService.js'; import { startHealthMonitorService, stopHealthMonitorService } from '../services/healthMonitorService.js'; @@ -100,6 +101,20 @@ function corsMiddleware(req, res, 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 (origin) { res.setHeader('Access-Control-Allow-Origin', origin); @@ -144,7 +159,7 @@ export async function buildApiApp() { app.use((req, res, next) => { // 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'); } next(); @@ -164,6 +179,7 @@ export async function buildApiApp() { } app.routeCount = await registerRoutes(app, '/api'); + app.use('/api/agent', agentRouter); app.use('/api/v1', v1Router); app.use((_req, res) => { diff --git a/src/api/middleware/agentAuth.js b/src/api/middleware/agentAuth.js new file mode 100644 index 0000000..1a37a15 --- /dev/null +++ b/src/api/middleware/agentAuth.js @@ -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' }); + } +} + diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index fe44ad7..658e881 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -63,6 +63,10 @@ export const authMiddleware = async (req, res, next) => { return next(); } + if (req.path.startsWith('/api/agent')) { + return next(); + } + if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) { return next(); } diff --git a/src/api/openapiSpec.js b/src/api/openapiSpec.js index 541ca0f..cd161fb 100644 --- a/src/api/openapiSpec.js +++ b/src/api/openapiSpec.js @@ -44,6 +44,7 @@ export const openApiOptions = { { name: 'Posterminal', description: 'Company-scoped POS terminals' }, { name: 'Printer', description: 'Company-scoped receipt and label printers' }, { 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: 'Stock', description: 'Manual stock additions and removals' }, { name: 'Warehouse', description: 'Company-scoped warehouses' }, @@ -56,6 +57,7 @@ export const openApiOptions = { apis: [ path.join(root, 'src/api/openapi.js'), path.join(root, 'src/api/routes/**/*.js'), + path.join(root, 'src/api/agent/**/*.js'), path.join(root, 'src/v1/**/*.js'), ], }; diff --git a/src/db/printeragent.js b/src/db/printeragent.js index 086ba76..4cabb9e 100644 --- a/src/db/printeragent.js +++ b/src/db/printeragent.js @@ -19,3 +19,28 @@ export async function remove(googleAuthId, id) { const store = await getStore(); 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); +}