diff --git a/server/src/config/version.js b/server/src/config/version.js new file mode 100644 index 0000000..0f2c4f9 --- /dev/null +++ b/server/src/config/version.js @@ -0,0 +1,21 @@ +// ── Public API version / identity ────────────────────────────────────────── +// +// A tiny, dependency-free descriptor of this backend, surfaced on GET +// /public/status and GET /public/version. A client (notably the Android app) +// uses it to: +// • positively recognize a Runic Gateway backend on first-run (the `service` +// tag), instead of guessing from an incidental response shape; and +// • run a version-mismatch guard — compare `api` against the contract the +// client was built against and surface a clear "update required" state +// rather than mis-parsing a future, changed response. +// +// `api` is the coarse contract version (bumped only on a breaking re-shape, which +// would be a v2 mount); `server` is the informational package version. + +const pkg = require('../../package.json') + +module.exports = { + service: 'runic-gateway', // stable backend identifier for first-run detection + api: 'v1', // API contract version (matches the /api/v1 mount) + server: pkg.version || '0.0.0', // server package version (informational) +} diff --git a/server/src/router/v1/public/public.controller.js b/server/src/router/v1/public/public.controller.js index 041a423..11a6a64 100644 --- a/server/src/router/v1/public/public.controller.js +++ b/server/src/router/v1/public/public.controller.js @@ -3,6 +3,7 @@ const wiki = require('../../../model/wiki/wiki.model') const settings = require('../../../model/settings/settings.model') const pages = require('../../../model/pages/pages.model') const mailer = require('../../../utils/mailer') +const version = require('../../../config/version') const { getUserFromRequest } = require('../../../utils/auth') const token = require('../../../auth/token') @@ -29,12 +30,22 @@ async function getStatus(req, res) { return res.json({ mode: (await settings.get('site_mode')) || 'live', status_message: (await settings.get('status_message')) || '', + // Identity + version, so a client's first-run probe recognizes a Runic + // Gateway backend and can run its version-mismatch guard in this one call. + version, }) } catch (err) { return res.status(500).json({ message: 'Internal Server Error' }) } } +// Lightweight, DB-free identity/version endpoint — the canonical target for a +// client's first-run recognition and its periodic version-mismatch guard, without +// touching settings or the database. Also serves as a cheap liveness check. +function getVersion(req, res) { + return res.json(version) +} + async function getPosts(req, res) { const { category } = req.params if (!posts.isValidUrlCategory(category)) { @@ -153,6 +164,7 @@ async function contact(req, res) { module.exports = { getSettings, getStatus, + getVersion, getPosts, getPost, getWikiCategories, diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index a11504e..3455ccf 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -22,10 +22,18 @@ publicRouter.get( '/status', // #swagger.tags = ['Public'] // #swagger.summary = 'Site mode / status' - // #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.' + // #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.' /* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicStatus" } } } } */ ctrl.getStatus, ) +publicRouter.get( + '/version', + // #swagger.tags = ['Public'] + // #swagger.summary = 'Backend identity + version' + // #swagger.description = 'Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.' + /* #swagger.responses[200] = { description: 'Backend version', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicVersion" } } } } */ + ctrl.getVersion, +) publicRouter.post( '/contact', // #swagger.tags = ['Public'] diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index ddbeb4d..ae82e82 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1662,7 +1662,7 @@ "Public" ], "summary": "Site mode / status", - "description": "Current site mode (live or maintenance) so the client can show the maintenance page.", + "description": "Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.", "responses": { "200": { "description": "Site status", @@ -1680,6 +1680,27 @@ } } }, + "/api/v1/public/version": { + "get": { + "tags": [ + "Public" + ], + "summary": "Backend identity + version", + "description": "Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.", + "responses": { + "200": { + "description": "Backend version", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicVersion" + } + } + } + } + } + } + }, "/api/v1/public/contact": { "post": { "tags": [ @@ -13907,6 +13928,78 @@ "example": "" } } + }, + "version": { + "$ref": "#/components/schemas/PublicVersion" + } + } + } + } + }, + "PublicVersion": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Backend identity + version (GET /public/version; also embedded in /public/status)." + }, + "properties": { + "type": "object", + "properties": { + "service": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "runic-gateway" + }, + "description": { + "type": "string", + "example": "Stable backend identifier for first-run recognition." + } + } + }, + "api": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "v1" + }, + "description": { + "type": "string", + "example": "API contract version (matches the /api/v1 mount)." + } + } + }, + "server": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "1.0.0" + }, + "description": { + "type": "string", + "example": "Server package version (informational)." + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index bd95200..a8930de 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -575,6 +575,16 @@ const doc = { properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' }, status_message: { type: 'string', example: '' }, + version: { $ref: '#/components/schemas/PublicVersion' }, + }, + }, + PublicVersion: { + type: 'object', + description: 'Backend identity + version (GET /public/version; also embedded in /public/status).', + properties: { + service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' }, + api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' }, + server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' }, }, }, // Delete/mutation acknowledgements — each echoes the affected resource key diff --git a/server/test/publicVersion.test.js b/server/test/publicVersion.test.js new file mode 100644 index 0000000..49a88bf --- /dev/null +++ b/server/test/publicVersion.test.js @@ -0,0 +1,36 @@ +// Point the DB at a closed port BEFORE requiring the router (some public handlers +// build the pool). The /version endpoint itself is DB-free, so it answers without +// a connection; this just guarantees no stray query holds the process open. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const { startApp } = require('./_helper') +const version = require('../src/config/version') +const publicRouter = require('../src/router/v1/public/public.routes') +const db = require('../src/utils/db') + +after(() => db.close()) + +test('version config carries the service id + api/server versions', () => { + assert.equal(version.service, 'runic-gateway') // stable first-run identifier + assert.equal(version.api, 'v1') // API contract version (matches /api/v1) + assert.equal(typeof version.server, 'string') + assert.ok(version.server.length > 0) +}) + +test('GET /public/version returns the identity block (DB-free, 200)', async () => { + const app = await startApp((a) => a.use('/api/v1/public', publicRouter)) + try { + const res = await fetch(app.url + '/api/v1/public/version') + assert.equal(res.status, 200) + const body = await res.json() + assert.equal(body.service, 'runic-gateway') + assert.equal(body.api, 'v1') + assert.equal(body.server, version.server) + } finally { + await app.close() + } +})