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

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 { 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) => {

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();
}
if (req.path.startsWith('/api/agent')) {
return next();
}
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
return next();
}

View File

@@ -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'),
],
};

View File

@@ -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);
}