From 676a40d4fe09b331d308300808840833d7d3b91e Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 29 Oct 2026 16:27:02 +0100 Subject: [PATCH] u --- docs/api/routes-all.md | 2 +- docs/api/routes/auth/me.md | 7 ++++--- src/api/auth/meJson.js | 15 +++++++++++++-- src/api/middleware/auth.js | 10 +++++----- src/api/routes/auth/me.js | 16 +++++++++++----- src/db/store.js | 16 ++++++++++++++++ 6 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 0930a02..25b159b 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -235,7 +235,7 @@ _none_ - Returns: `200 { message: str }` #### Current user — `GET /auth/me` _none_ -- Returns: `200 { user: T32, googleClientId: str }` +- Returns: `200 { user: T32, googleClientId: str, schemaVersion: int }` #### Set selected company — `PUT /auth/selected-company` - Params: `body: { companyId: int }` - Returns: `200 { user: T32 }` diff --git a/docs/api/routes/auth/me.md b/docs/api/routes/auth/me.md index ec37c91..d656307 100644 --- a/docs/api/routes/auth/me.md +++ b/docs/api/routes/auth/me.md @@ -6,7 +6,7 @@ Current user Returns the authenticated user from the session cookie and the Google client ID. `user.picture` is the stored profile image as a base64 string when present. -Unauthenticated calls still receive `googleClientId` with 401. +Unauthenticated calls still receive `googleClientId` and `schemaVersion` with 401. ## Response `200` @@ -21,7 +21,8 @@ Authenticated "selectedCompanyId": 1, "picture": "…" }, - "googleClientId": "…" + "googleClientId": "…", + "schemaVersion": 1 } ``` @@ -29,7 +30,7 @@ Authenticated | Status | Description | |--------|-------------| -| 401 | Unauthorized — still includes `googleClientId` for the frontend | +| 401 | Unauthorized — still includes `googleClientId` and `schemaVersion` for the frontend | --- diff --git a/src/api/auth/meJson.js b/src/api/auth/meJson.js index b2118a2..d50279d 100644 --- a/src/api/auth/meJson.js +++ b/src/api/auth/meJson.js @@ -1,5 +1,16 @@ import env from '../../config/env.js'; +import { getSchemaVersion } from '../../db/store.js'; -export function respondWithAuthenticatedUser(res, status, body) { - return res.status(status).json({ ...body, googleClientId: env.googleClientId }); +export async function respondWithAuthenticatedUser(res, status, body) { + let schemaVersion = null; + try { + schemaVersion = await getSchemaVersion(); + } catch { + // ignore if database not accessible + } + return res.status(status).json({ + ...body, + googleClientId: env.googleClientId, + schemaVersion, + }); } diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index bb7612a..fe44ad7 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -47,7 +47,7 @@ export function clearCachedCompanyUsers(companyId) { } } -function respondWithAuthPayload(res, req, status, body) { +async function respondWithAuthPayload(res, req, status, body) { if (req.path === '/api/auth/me') { return respondWithAuthenticatedUser(res, status, body); } @@ -85,7 +85,7 @@ export const authMiddleware = async (req, res, next) => { const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs'); if (!token) { if (isDocs || isOAuthAuthorize) return next(); - return respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' }); + return await respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' }); } try { @@ -94,7 +94,7 @@ export const authMiddleware = async (req, res, next) => { if (!Number.isInteger(authId) || authId <= 0) { if (req.cookies.token) clearTokenCookie(res); if (isDocs) return next(); - return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' }); + return await respondWithAuthPayload(res, req, 401, { error: 'Invalid token' }); } let user = getCachedUser(authId); @@ -111,7 +111,7 @@ export const authMiddleware = async (req, res, next) => { clearCachedUser(authId); if (req.cookies.token) clearTokenCookie(res); if (isDocs) return next(); - return respondWithAuthPayload(res, req, 403, { error: 'User is no longer authorized' }); + return await respondWithAuthPayload(res, req, 403, { error: 'User is no longer authorized' }); } req.user = { @@ -133,7 +133,7 @@ export const authMiddleware = async (req, res, next) => { } catch { if (req.cookies.token) clearTokenCookie(res); if (isDocs || isOAuthAuthorize) return next(); - return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' }); + return await respondWithAuthPayload(res, req, 401, { error: 'Invalid token' }); } }; diff --git a/src/api/routes/auth/me.js b/src/api/routes/auth/me.js index 497f67c..f050380 100644 --- a/src/api/routes/auth/me.js +++ b/src/api/routes/auth/me.js @@ -17,7 +17,7 @@ export function register(app, basePath = '') { * description: | * Returns the authenticated user from the session cookie and the Google client ID. * `user.picture` is the stored profile image as a base64 string when present. - * Unauthenticated calls still receive `googleClientId` with 401. + * Unauthenticated calls still receive `googleClientId` and `schemaVersion` with 401. * security: * - cookieAuth: [] * x-markdown: auth/me.md @@ -33,8 +33,11 @@ export function register(app, basePath = '') { * $ref: '#/components/schemas/User' * googleClientId: * type: string + * schemaVersion: + * type: integer + * description: Current database schema version * 401: - * description: Unauthorized — still includes `googleClientId` for the frontend + * description: Unauthorized — still includes `googleClientId` and `schemaVersion` for the frontend * content: * application/json: * schema: @@ -44,12 +47,15 @@ export function register(app, basePath = '') { * type: string * googleClientId: * type: string + * schemaVersion: + * type: integer + * description: Current database schema version */ app.get(`${basePath}/auth/me`, async (req, res) => { try { const row = await db.getById(req.user.id); if (!row) { - return respondWithAuthenticatedUser(res, 403, { error: 'User is no longer authorized' }); + return await respondWithAuthenticatedUser(res, 403, { error: 'User is no longer authorized' }); } const pictureBinary = toPictureBuffer(row.pictureBinary); @@ -59,10 +65,10 @@ export function register(app, basePath = '') { selectedCompanyId: row.selectedCompanyId, ...(pictureBinary && { picture: pictureBinary.toString('base64') }), }; - respondWithAuthenticatedUser(res, 200, { user }); + await respondWithAuthenticatedUser(res, 200, { user }); } catch (error) { console.error('Get current user failed:', error); - respondWithAuthenticatedUser(res, 500, { error: 'Failed to load user' }); + await respondWithAuthenticatedUser(res, 500, { error: 'Failed to load user' }); } }); } diff --git a/src/db/store.js b/src/db/store.js index 76eeca5..019e3b6 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -10,6 +10,7 @@ const schemaLog = createLogger('schema'); let provider; let store; let connecting; +let schemaVersion; const STORE_NAMESPACES = [ 'companyuser', @@ -149,6 +150,7 @@ export async function getStore() { provider = createProvider(); try { await provider.initialize((msg) => schemaLog.info(msg)); + schemaVersion = await provider.getSchemaVersion(); } catch (err) { schemaLog.error('Database schema initialization failed:', err); throw err; @@ -168,6 +170,19 @@ export async function getProvider() { return provider; } +/** + * Returns the current database schema version. + * @returns {Promise} + */ +export async function getSchemaVersion() { + if (schemaVersion != null) { + return schemaVersion; + } + const prov = await getProvider(); + schemaVersion = await prov.getSchemaVersion(); + return schemaVersion; +} + export async function ensureConnection() { await getStore(); } @@ -177,6 +192,7 @@ export async function closeStore() { await provider.close(); provider = undefined; store = undefined; + schemaVersion = undefined; } }