u
This commit is contained in:
@@ -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`.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
16242
docs/api/openapi.json
Normal file
16242
docs/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
20
package-lock.json
generated
20
package-lock.json
generated
@@ -27,7 +27,8 @@
|
||||
"react": "^19.3.0",
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.5"
|
||||
"sharp": "^0.35.5",
|
||||
"swagger-ui-dist": "^5.33.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
@@ -61,7 +62,6 @@
|
||||
}
|
||||
},
|
||||
"../llmorbiter": {
|
||||
"name": "llmorbiter",
|
||||
"version": "0.1.0",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
@@ -909,6 +909,13 @@
|
||||
"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": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3911,6 +3918,15 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"react": "^19.3.0",
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.5"
|
||||
"sharp": "^0.35.5",
|
||||
"swagger-ui-dist": "^5.33.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
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 root = path.resolve(__dirname, '..');
|
||||
@@ -44,55 +44,7 @@ function isRouteOmitted(method, apiPath) {
|
||||
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
||||
}
|
||||
|
||||
const options = {
|
||||
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);
|
||||
const spec = getOpenApiSpec(true);
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -756,5 +708,6 @@ for (const apiPath of paths) {
|
||||
const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
|
||||
fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations));
|
||||
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/`);
|
||||
|
||||
@@ -47,7 +47,7 @@ function authMeJson(res, req, status, body) {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -59,14 +59,23 @@ export const authMiddleware = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.cookies.token;
|
||||
if (!token) return authMeJson(res, req, 401, { error: 'Unauthorized' });
|
||||
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
|
||||
? 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 {
|
||||
const decoded = jwt.verify(token, env.jwtSecret);
|
||||
const authId = Number(decoded.id);
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -82,7 +91,8 @@ export const authMiddleware = async (req, res, next) => {
|
||||
}
|
||||
if (!user) {
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ export const authMiddleware = async (req, res, next) => {
|
||||
};
|
||||
|
||||
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' });
|
||||
res.cookie('token', newToken, {
|
||||
...TOKEN_COOKIE,
|
||||
@@ -103,7 +113,9 @@ export const authMiddleware = async (req, res, next) => {
|
||||
|
||||
next();
|
||||
} catch {
|
||||
clearTokenCookie(res);
|
||||
if (req.cookies.token) clearTokenCookie(res);
|
||||
if (isDocs) return next();
|
||||
return authMeJson(res, req, 401, { error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
* in: cookie
|
||||
* name: token
|
||||
* 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:
|
||||
* Error:
|
||||
* type: object
|
||||
|
||||
69
src/api/openapiSpec.js
Normal file
69
src/api/openapiSpec.js
Normal 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
111
src/api/routes/docs.js
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user