Merge pull request 'txt-game: become a nox mesh module' (#6) from nox-mesh into main
This commit was merged in pull request #6.
This commit is contained in:
+5
-1
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+95
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-4
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
@@ -14,14 +15,33 @@ const MAX_ATTEMPTS = 7;
|
|||||||
|
|
||||||
// ─── DB Pool ────────────────────────────────────────────────────────────────
|
// ─── 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 = [
|
const DB_ENV_KEYS = [
|
||||||
"TXT_GAME_DB_HOST",
|
"TXT_GAME_DB_HOST",
|
||||||
"TXT_GAME_DB_PORT",
|
"TXT_GAME_DB_PORT",
|
||||||
"TXT_GAME_DB_USER",
|
"TXT_GAME_DB_USER",
|
||||||
"TXT_GAME_DB_PASSWORD",
|
|
||||||
"TXT_GAME_DB_NAME",
|
"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} */
|
/** @type {pg.Pool | null} */
|
||||||
let pool = null;
|
let pool = null;
|
||||||
@@ -31,7 +51,7 @@ if (hasDb) {
|
|||||||
host: process.env.TXT_GAME_DB_HOST,
|
host: process.env.TXT_GAME_DB_HOST,
|
||||||
port: parseInt(process.env.TXT_GAME_DB_PORT ?? "5432", 10),
|
port: parseInt(process.env.TXT_GAME_DB_PORT ?? "5432", 10),
|
||||||
user: process.env.TXT_GAME_DB_USER,
|
user: process.env.TXT_GAME_DB_USER,
|
||||||
password: process.env.TXT_GAME_DB_PASSWORD,
|
password,
|
||||||
database: process.env.TXT_GAME_DB_NAME,
|
database: process.env.TXT_GAME_DB_NAME,
|
||||||
max: 5,
|
max: 5,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
@@ -44,10 +64,63 @@ if (hasDb) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
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 ──────────────────────────────────────────────────────────
|
// ─── Session Store ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -542,6 +615,8 @@ const server = createServer(async (req, res) => {
|
|||||||
res.end("Not found");
|
res.end("Not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ensureSchema();
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`txt-game listening on port ${PORT}`);
|
console.log(`txt-game listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user