This commit is contained in:
seb
2026-10-01 22:48:44 +02:00
parent ea51f30a8d
commit 20886e7c76
9 changed files with 16471 additions and 62 deletions

View File

@@ -11,7 +11,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da
- Auth: `src/api/middleware/auth.js` (Google SSO + JWT cookie, `req.user.{id,selectedCompanyId}`); allowlist via `scripts/manageUsers.js`. - Auth: `src/api/middleware/auth.js` (Google SSO + JWT cookie, `req.user.{id,selectedCompanyId}`); allowlist via `scripts/manageUsers.js`.
- SSE: `src/api/sse.js` (`emit`), `routes/events.js`. Stock adjust emits `stock`, `ledger`, `articles`, `article-<sku>`. - SSE: `src/api/sse.js` (`emit`), `routes/events.js`. Stock adjust emits `stock`, `ledger`, `articles`, `article-<sku>`.
- `src/v1/` legacy POS compat — don't touch unless task is POS. `quixpos2jtl/` is a separate Electron/CLI bridge — ignore unless task mentions JTL. - `src/v1/` legacy POS compat — don't touch unless task is POS. `quixpos2jtl/` is a separate Electron/CLI bridge — ignore unless task mentions JTL.
- Docs: generated, never hand-edit — see below. - Docs: generated, never hand-edit — see below. Interactive API documentation & tester served under `/docs/` (`/docs/openapi.json`).
## Build / Test / Docs ## Build / Test / Docs

16242
docs/api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

20
package-lock.json generated
View File

@@ -27,7 +27,8 @@
"react": "^19.3.0", "react": "^19.3.0",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor", "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"selfsigned": "^5.5.0", "selfsigned": "^5.5.0",
"sharp": "^0.35.5" "sharp": "^0.35.5",
"swagger-ui-dist": "^5.33.1"
}, },
"devDependencies": { "devDependencies": {
"swagger-jsdoc": "^6.3.0" "swagger-jsdoc": "^6.3.0"
@@ -61,7 +62,6 @@
} }
}, },
"../llmorbiter": { "../llmorbiter": {
"name": "llmorbiter",
"version": "0.1.0", "version": "0.1.0",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
@@ -909,6 +909,13 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3911,6 +3918,15 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/swagger-ui-dist": {
"version": "5.33.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz",
"integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/tagged-tag": { "node_modules/tagged-tag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",

View File

@@ -32,7 +32,8 @@
"react": "^19.3.0", "react": "^19.3.0",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor", "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"selfsigned": "^5.5.0", "selfsigned": "^5.5.0",
"sharp": "^0.35.5" "sharp": "^0.35.5",
"swagger-ui-dist": "^5.33.1"
}, },
"devDependencies": { "devDependencies": {
"swagger-jsdoc": "^6.3.0" "swagger-jsdoc": "^6.3.0"

View File

@@ -6,7 +6,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc'; import { openApiOptions, getOpenApiSpec } from '../src/api/openapiSpec.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
@@ -44,55 +44,7 @@ function isRouteOmitted(method, apiPath) {
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`); return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
} }
const options = { const spec = getOpenApiSpec(true);
definition: {
openapi: '3.0.3',
info: {
title: 'Dash API',
version: '1.0.0',
description:
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
'Browser clients must send cookies (`credentials: \'include\'`). ' +
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
},
servers: [
{ url: 'http://localhost:4208/api', description: 'Local default HTTP (PORT / .env)' },
{ url: 'https://localhost:8443', description: 'Local default HTTPS (HTTPS_PORT / .env)' },
],
tags: [
{ name: 'Auth', description: 'Google SSO and session' },
{ name: 'Health', description: 'Connectivity checks' },
{ name: 'POS', description: 'POS bind and sync API (/v1)' },
{ name: 'Events', description: 'SSE update signals for live clients' },
{ name: 'Article', description: 'Articles under categories' },
{ name: 'Category', description: 'Category tree and membership' },
{ name: 'Company', description: 'Company CRUD' },
{ name: 'CompanySetting', description: 'Company-scoped settings' },
{ name: 'Unit', description: 'Company-scoped units of measure' },
{ name: 'Brand', description: 'Company-scoped brands' },
{ name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' },
{ 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: 'Customergroup', description: 'Company-scoped customer groups' },
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
{ name: 'Ledger', description: 'Append-only ledger transactions' },
{ name: 'Stock', description: 'Manual stock additions and removals' },
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
],
},
apis: [
path.join(root, 'src/api/openapi.js'),
path.join(root, 'src/api/routes/**/*.js'),
path.join(root, 'src/v1/**/*.js'),
],
};
const spec = swaggerJsdoc(options);
function ensureDir(dir) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -756,5 +708,6 @@ for (const apiPath of paths) {
const overviewOperations = operations.filter((op) => !op.omittedFromOverview); const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations)); fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations));
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations)); fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2));
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md → docs/api/`); console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + openapi.json → docs/api/`);

View File

@@ -47,7 +47,7 @@ function authMeJson(res, req, status, body) {
} }
export const authMiddleware = async (req, res, next) => { export const authMiddleware = async (req, res, next) => {
if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health') { if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health' || req.path === '/api/openapi.json') {
return next(); return next();
} }
@@ -59,14 +59,23 @@ export const authMiddleware = async (req, res, next) => {
return next(); return next();
} }
const token = req.cookies.token; const bearerToken = req.headers.authorization?.startsWith('Bearer ')
if (!token) return authMeJson(res, req, 401, { error: 'Unauthorized' }); ? req.headers.authorization.slice(7).trim()
: null;
const token = req.cookies.token || bearerToken;
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
if (!token) {
if (isDocs) return next();
return authMeJson(res, req, 401, { error: 'Unauthorized' });
}
try { try {
const decoded = jwt.verify(token, env.jwtSecret); const decoded = jwt.verify(token, env.jwtSecret);
const authId = Number(decoded.id); const authId = Number(decoded.id);
if (!Number.isInteger(authId) || authId <= 0) { if (!Number.isInteger(authId) || authId <= 0) {
clearTokenCookie(res); if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return authMeJson(res, req, 401, { error: 'Invalid token' }); return authMeJson(res, req, 401, { error: 'Invalid token' });
} }
@@ -82,7 +91,8 @@ export const authMiddleware = async (req, res, next) => {
} }
if (!user) { if (!user) {
clearCachedUser(authId); clearCachedUser(authId);
clearTokenCookie(res); if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return authMeJson(res, req, 403, { error: 'User is no longer authorized' }); return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
} }
@@ -93,7 +103,7 @@ export const authMiddleware = async (req, res, next) => {
}; };
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
if (decoded.exp - now < 2 * 60 * 60) { if (req.cookies.token && decoded.exp - now < 2 * 60 * 60) {
const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' }); const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' });
res.cookie('token', newToken, { res.cookie('token', newToken, {
...TOKEN_COOKIE, ...TOKEN_COOKIE,
@@ -103,7 +113,9 @@ export const authMiddleware = async (req, res, next) => {
next(); next();
} catch { } catch {
clearTokenCookie(res); if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return authMeJson(res, req, 401, { error: 'Invalid token' }); return authMeJson(res, req, 401, { error: 'Invalid token' });
} }
}; };

View File

@@ -10,6 +10,11 @@
* in: cookie * in: cookie
* name: token * name: token
* description: HTTP-only session cookie set by POST /auth/login * description: HTTP-only session cookie set by POST /auth/login
* bearerAuth:
* type: http
* scheme: bearer
* bearerFormat: JWT
* description: JWT token in Authorization header (Bearer <token>)
* schemas: * schemas:
* Error: * Error:
* type: object * type: object

69
src/api/openapiSpec.js Normal file
View File

@@ -0,0 +1,69 @@
import path from 'path';
import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '../..');
export const openApiOptions = {
definition: {
openapi: '3.0.3',
info: {
title: 'Dash API',
version: '1.0.0',
description:
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
'Browser clients must send cookies (`credentials: \'include\'`) or an `Authorization: Bearer <token>` header. ' +
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
},
servers: [
{ url: '/api', description: 'API Base (/api)' },
{ url: '/', description: 'Root (/)' },
],
tags: [
{ name: 'Auth', description: 'Google SSO and session' },
{ name: 'Health', description: 'Connectivity checks' },
{ name: 'POS', description: 'POS bind and sync API (/v1)' },
{ name: 'Events', description: 'SSE update signals for live clients' },
{ name: 'Article', description: 'Articles under categories' },
{ name: 'Category', description: 'Category tree and membership' },
{ name: 'Company', description: 'Company CRUD' },
{ name: 'CompanySetting', description: 'Company-scoped settings' },
{ name: 'Unit', description: 'Company-scoped units of measure' },
{ name: 'Brand', description: 'Company-scoped brands' },
{ name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' },
{ 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: 'Customergroup', description: 'Company-scoped customer groups' },
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
{ name: 'Ledger', description: 'Append-only ledger transactions' },
{ name: 'Stock', description: 'Manual stock additions and removals' },
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
],
},
apis: [
path.join(root, 'src/api/openapi.js'),
path.join(root, 'src/api/routes/**/*.js'),
path.join(root, 'src/v1/**/*.js'),
],
};
let cachedSpec = null;
/**
* Returns the parsed OpenAPI 3.0 specification object, cached in memory.
* @param {boolean} [refresh=false] - Force rebuild of the spec
* @returns {object}
*/
export function getOpenApiSpec(refresh = false) {
if (!cachedSpec || refresh) {
cachedSpec = swaggerJsdoc(openApiOptions);
}
return cachedSpec;
}

111
src/api/routes/docs.js Normal file
View File

@@ -0,0 +1,111 @@
import express from 'express';
import swaggerUiDist from 'swagger-ui-dist';
import { getOpenApiSpec } from '../openapiSpec.js';
const swaggerDistPath = swaggerUiDist.getAbsoluteFSPath();
function renderDocsHtml() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dash API</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html {
box-sizing: border-box;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"></script>
<script src="./swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: './openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: 'StandaloneLayout',
requestInterceptor: (req) => {
req.credentials = 'include';
return req;
}
});
};
</script>
</body>
</html>`;
}
/**
* Registers documentation and interactive API tester under /docs/.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Base path (e.g. '/api')
*/
export function register(app, basePath = '') {
const docsRouter = express.Router();
// Redirect /docs (or /api/docs) to /docs/ so relative assets resolve properly
docsRouter.use((req, res, next) => {
const rawPath = req.originalUrl.split('?')[0];
if (rawPath === '/docs' || rawPath === '/api/docs' || rawPath === '/api/docs/') {
const query = req.originalUrl.includes('?') ? '?' + req.originalUrl.split('?')[1] : '';
return res.redirect(301, '/docs/' + query);
}
next();
});
// Serve OpenAPI 3.0 specification JSON
docsRouter.get('/openapi.json', (_req, res) => {
try {
const spec = getOpenApiSpec();
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send(JSON.stringify(spec, null, 2));
} catch (err) {
console.error('Failed to generate OpenAPI spec:', err);
res.status(500).json({ error: 'Failed to generate OpenAPI specification' });
}
});
// Serve HTML UI for /docs/ and /docs/index.html
docsRouter.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderDocsHtml());
});
docsRouter.get('/index.html', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderDocsHtml());
});
// Serve static assets from swagger-ui-dist (CSS, JS bundles, icons)
docsRouter.use(express.static(swaggerDistPath, { index: false }));
// Mount on /docs
app.use('/docs', docsRouter);
// Also mount on /api/docs and alias /api/openapi.json if basePath is set
if (basePath) {
app.use(basePath + '/docs', docsRouter);
app.get(basePath + '/openapi.json', (_req, res) => res.redirect(301, '/docs/openapi.json'));
}
}