txt-game: become a nox mesh module

The mesh builds the game from this repository and a commit (novox/hq ADR
0069); registry-api.novox.be, where HAL's image came from, is retired, so
the running container can no longer be re-pulled. The HAL module.yml,
docker-compose.yml and migrations/ beside this manifest retire with HAL.

The score database is a postgres-database grant: host, port, login and
database come from the binding (in a file the container reads as its
environment), the password from a 0600 file named by
TXT_GAME_DB_PASSWORD_FILE (ADR 0086). TXT_GAME_DB_PASSWORD still works,
so HAL's deployment keeps running if it is rebuilt from main.

The app now creates its schema when the database has none. HAL's
provisioning migrations did that as the admin and then granted the app's
login; under the mesh the app's own login owns its database, so the app
makes its tables itself. A database that already has both tables - HAL's
or one restored from it - is left exactly as it is.

The base is node 22.23.2-alpine3.24 by digest, the image the running one
was built on (its base layers match).

Verified: the manifest parses on mesh-controller main and #149 and
renders for a zurag.be node. Built from this commit, against throwaway
postgres: on an empty granted database it creates both tables (owned by
the granted login) and records a game; on a HAL-shaped database (tables
owned by postgres, grants to the old login, password in the environment)
it plays and creates nothing; and a pg_dump/pg_restore --no-owner copy of
that database into a fresh granted one keeps every row (counts and md5
identical), is owned by the grant, and keeps taking games.
This commit is contained in:
2026-09-30 12:05:34 +02:00
parent eab696ce42
commit 737152d88b
3 changed files with 179 additions and 5 deletions
+79 -4
View File
@@ -7,6 +7,7 @@
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import pg from "pg";
const PORT = 3000;
@@ -14,14 +15,33 @@ const MAX_ATTEMPTS = 7;
// ─── DB Pool ────────────────────────────────────────────────────────────────
// Where the database is comes from the environment; the password comes from a file when
// TXT_GAME_DB_PASSWORD_FILE names one (how the mesh delivers a secret — novox/hq ADR 0086: a
// secret in the environment is readable by anything that can inspect the container), and
// from TXT_GAME_DB_PASSWORD otherwise, which is how HAL still delivers it.
const DB_ENV_KEYS = [
"TXT_GAME_DB_HOST",
"TXT_GAME_DB_PORT",
"TXT_GAME_DB_USER",
"TXT_GAME_DB_PASSWORD",
"TXT_GAME_DB_NAME",
];
const hasDb = DB_ENV_KEYS.every((k) => process.env[k]);
/** @returns {string | undefined} */
function dbPassword() {
const file = process.env.TXT_GAME_DB_PASSWORD_FILE;
if (file) {
try {
return readFileSync(file, "utf8").replace(/\r?\n$/, "") || undefined;
} catch (err) {
console.error(`[txt-game] cannot read TXT_GAME_DB_PASSWORD_FILE: ${err.message}`);
return undefined;
}
}
return process.env.TXT_GAME_DB_PASSWORD || undefined;
}
const password = dbPassword();
const hasDb = DB_ENV_KEYS.every((k) => process.env[k]) && password !== undefined;
/** @type {pg.Pool | null} */
let pool = null;
@@ -31,7 +51,7 @@ if (hasDb) {
host: process.env.TXT_GAME_DB_HOST,
port: parseInt(process.env.TXT_GAME_DB_PORT ?? "5432", 10),
user: process.env.TXT_GAME_DB_USER,
password: process.env.TXT_GAME_DB_PASSWORD,
password,
database: process.env.TXT_GAME_DB_NAME,
max: 5,
idleTimeoutMillis: 30000,
@@ -44,10 +64,63 @@ if (hasDb) {
);
} else {
console.warn(
"[txt-game] DB env vars missing — running in degraded mode (scores disabled)"
"[txt-game] DB settings missing — running in degraded mode (scores disabled)"
);
}
// ─── Schema ─────────────────────────────────────────────────────────────────
/**
* Create the score tables when the database has none — what HAL's provisioning migrations
* (migrations/provision/postgres) did as the admin before the app first started. Under the mesh
* the app's own login owns its database, so the app makes its schema itself. A database that
* already has both tables (HAL's, or one restored from it) is left exactly as it is: nothing
* here alters or grants on existing tables.
*/
async function ensureSchema() {
if (!pool) return;
try {
const { rows } = await pool.query(
"SELECT to_regclass('public.players') IS NOT NULL AS players, to_regclass('public.games') IS NOT NULL AS games"
);
if (rows[0].players && rows[0].games) return;
await pool.query(`
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
games_played INT NOT NULL DEFAULT 0,
games_won INT NOT NULL DEFAULT 0,
games_lost INT NOT NULL DEFAULT 0,
best_attempts INT,
current_streak INT NOT NULL DEFAULT 0,
best_streak INT NOT NULL DEFAULT 0,
last_played_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS games (
id BIGSERIAL PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
target INT NOT NULL,
attempts INT NOT NULL,
won BOOLEAN NOT NULL,
finished_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_players_scoreboard
ON players (best_attempts NULLS LAST, last_played_at)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_games_player_history
ON games (player_id, finished_at DESC)
`);
console.log("[txt-game] score schema created");
} catch (err) {
console.error("[txt-game] could not ensure the score schema:", err.message);
}
}
// ─── Session Store ──────────────────────────────────────────────────────────
/**
@@ -542,6 +615,8 @@ const server = createServer(async (req, res) => {
res.end("Not found");
});
await ensureSchema();
server.listen(PORT, () => {
console.log(`txt-game listening on port ${PORT}`);
});