feat(public): version/health surfacing for the app first-run probe
Expose a small backend identity/version descriptor (§8.4 of the Android plan)
so a client can positively recognize a Runic Gateway backend on first-run and
run a version-mismatch guard, instead of inferring from an incidental shape.
- New config/version.js: { service: 'runic-gateway', api: 'v1', server: <pkg> }.
- GET /public/status now includes a `version` block (the app already calls this
on first-run, so it gets identity + version in one round trip).
- New GET /public/version: a lightweight, DB-free identity endpoint — the
canonical target for the version guard and a cheap liveness check.
- Swagger: PublicVersion schema + version on PublicStatus; /version annotated.
- test/publicVersion.test.js covers the config shape and the DB-free 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
21
server/src/config/version.js
Normal file
21
server/src/config/version.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
36
server/test/publicVersion.test.js
Normal file
36
server/test/publicVersion.test.js
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user