diff --git a/Dockerfile b/Dockerfile index 14989da..7194274 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ -FROM node:22-alpine +# The base arrives pinned from module.json's build.on (node 22.23.2 on alpine 3.24, the image +# the running deployment was built from); the mesh builds this image from this repository and +# a commit (novox/hq ADR 0069). The build context is the repository root. +ARG NODE_BASE +FROM ${NODE_BASE} WORKDIR /app diff --git a/module.json b/module.json new file mode 100644 index 0000000..0c569dc --- /dev/null +++ b/module.json @@ -0,0 +1,95 @@ +{ + "module": "txt-game", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "txt-game" + }, + "route": { + "label": "txt-game", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "listens": [ + { + "name": "web", + "port": 3000, + "protocol": "tcp", + "from": "mesh", + "why": "the game's pages and form posts over http; its public name is a route grant and the proxy reaches it here" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "database-env", + "type": "file", + "path": "${dir:state}/database.env", + "mode": "0644", + "content": "TXT_GAME_DB_HOST=${bound:postgres-database:at}\nTXT_GAME_DB_PORT=${bound:postgres-database:port}\nTXT_GAME_DB_USER=${bound:postgres-database:as}\nTXT_GAME_DB_NAME=${bound:postgres-database:as}\n" + }, + { + "id": "database-password", + "type": "file", + "path": "${dir:state}/database-password", + "mode": "0600", + "content": "${secret:postgres-database}" + }, + { + "id": "server", + "type": "container", + "name": "txt-game", + "artifact": "app", + "ports": [ + "3000" + ], + "env": { + "TXT_GAME_DB_PASSWORD_FILE": "/run/secrets/database" + }, + "env-file": [ + "${dir:state}/database.env" + ], + "volumes": [ + "${dir:state}/database-password:/run/secrets/database:ro" + ], + "restart-on": [ + "database-env", + "database-password" + ] + } + ], + "build": { + "on": [ + { + "arg": "NODE_BASE", + "image": "node@sha256:b6f26b36c8ff49624cfdac716b8ea1138d606df02586a77d364bb5536a634f85" + } + ], + "artifacts": [ + { + "name": "app", + "kind": "image", + "from": "Dockerfile" + } + ] + } +} diff --git a/server.mjs b/server.mjs index 4ea9b9c..991070d 100644 --- a/server.mjs +++ b/server.mjs @@ -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}`); });