vps-health-api/backups.js
le king fu 329d83e4e5
All checks were successful
CI / verification (pull_request) Successful in 35s
feat(backups): pass the files[] block of status.json through GET /backups
backup-postgres.sh now writes files[], one entry per archived host
directory (la-compagnie-maximus#295). sanitizeBackupsStatus() dropped
it as an unknown field, so a failed archive turned the status red with
no cause. It is now rebuilt from a whitelist like databases[], with a
closed code list of its own (source_missing, archive_failed,
encrypt_failed, invalid_name), the script's name pattern with a 63 cap,
a nullable archivedAt and a bound of 16. A status without files keeps
working.

Refs #61

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:04:12 -04:00

204 lines
7.6 KiB
JavaScript

// PostgreSQL backups (GET /backups, POST /backups/pull), issue #58, contract
// `schemaVersion: 1` shared with the VPS script (la-compagnie-maximus,
// scripts/backup/backup-postgres.sh) and the workstation pull agent
// (agent/pg-backup-pull/run-pg-backup-pull.sh).
//
// Pure module: no I/O, no config, no side effects. index.js wires the routes,
// reads status.json and owns the pull file.
//
// Two inputs, two rules:
// - status.json is written by the VPS script and only read here. It goes
// through a whitelist rebuild, as the Claude usage snapshot does: an
// unknown field is dropped, an invalid value is dropped (key left out),
// never coerced. Only what makes the file unusable refuses it as a whole:
// not an object, a `schemaVersion` other than 1, or no usable `generatedAt`
// (the age, hence `stale`, is computed from it).
// - the pull report is sent by the workstation agent, whose body is four
// fixed fields. It is validated strictly: any invalid or missing field is a
// 400, so a broken agent is told so instead of being half-stored.
// Error codes come from closed lists on both sides: a free-form `error` could
// carry pg_dump or rclone stderr (hosts, keys) all the way to the dashboard.
const { isIsoTimestamp } = require("./claude-usage.js");
const SCHEMA_VERSION = 1;
// The pull body is four short fields, under 120 bytes in practice.
const BACKUPS_MAX_BODY_BYTES = 1024;
// One cluster holds about ten databases; this bound is only there so a runaway
// file cannot make the response arbitrarily large.
const MAX_DATABASES = 64;
const VPS_ERROR_CODES = [
"dump_failed",
"dump_empty",
"restore_list_failed",
"invalid_name",
"encrypt_failed",
"mirror_upload_failed",
"mirror_purge_failed",
"rotation_failed",
];
const PULL_ERROR_CODES = ["pull_failed", "rotation_failed"];
// The host directories the VPS script archives (FILE_SOURCES, issue #61): a
// list of its own, exactly what the script can write for a file entry, so a
// database code never passes on a file and the reverse.
const FILE_ERROR_CODES = ["source_missing", "archive_failed", "encrypt_failed", "invalid_name"];
// One directory today (claude-usage); the bound only caps a runaway file.
const MAX_FILES = 16;
// Same pattern as the VPS script's own check. A name it rejected is reported
// with `invalid_name` and may hold arbitrary text: that name is dropped here.
const DATABASE_NAME_RE = /^[a-z0-9_]{1,63}$/;
// The script's own file pattern, hyphen allowed. The script sets no length;
// the 63 cap is this API's bound, as for databases.
const FILE_NAME_RE = /^[a-z0-9_-]{1,63}$/;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cleanCount(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function cleanBoolean(value) {
return typeof value === "boolean" ? value : undefined;
}
function cleanTimestamp(value) {
return isIsoTimestamp(value) ? value : undefined;
}
function cleanCode(value, codes) {
return codes.includes(value) ? value : undefined;
}
function cleanDatabaseName(value) {
return typeof value === "string" && DATABASE_NAME_RE.test(value) ? value : undefined;
}
function cleanFileName(value) {
return typeof value === "string" && FILE_NAME_RE.test(value) ? value : undefined;
}
// Copy `key` into `out` only when the cleaner kept a value.
function put(out, key, value) {
if (value !== undefined) out[key] = value;
}
// `null` is a legitimate value for the fields the contract declares nullable
// (a failed dump has no dumpedAt, a failed upload no uploadedAt).
function putNullable(out, key, raw, clean) {
if (raw === null) out[key] = null;
else put(out, key, clean(raw));
}
// { ok, error? } plus the given extra fields, for globals, mirror and rotation.
function sanitizeComponent(value, extra) {
if (!isPlainObject(value)) return undefined;
const out = {};
extra(out, value);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, VPS_ERROR_CODES));
return out;
}
function sanitizeDatabase(value) {
const out = {};
put(out, "name", cleanDatabaseName(value.name));
put(out, "sizeBytes", cleanCount(value.sizeBytes));
putNullable(out, "dumpedAt", value.dumpedAt, cleanTimestamp);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, VPS_ERROR_CODES));
return out;
}
// archivedAt is null when no archive was made (source_missing, archive_failed),
// and set on an encrypt_failed entry, whose tar did succeed.
function sanitizeFile(value) {
const out = {};
put(out, "name", cleanFileName(value.name));
put(out, "sizeBytes", cleanCount(value.sizeBytes));
putNullable(out, "archivedAt", value.archivedAt, cleanTimestamp);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, FILE_ERROR_CODES));
return out;
}
function sanitizeRetention(value) {
if (!isPlainObject(value)) return undefined;
const out = {};
put(out, "local", cleanCount(value.local));
put(out, "offsite", cleanCount(value.offsite));
return out;
}
// Returns { status } or { error: <code> }, the code going out as `vpsError`.
// Idempotent: a sanitized status comes back unchanged.
function sanitizeBackupsStatus(payload) {
if (!isPlainObject(payload)) return { error: "invalid_contract" };
if (payload.schemaVersion !== SCHEMA_VERSION) return { error: "unsupported_schema" };
const generatedAt = cleanTimestamp(payload.generatedAt);
if (generatedAt === undefined) return { error: "invalid_contract" };
const status = { schemaVersion: SCHEMA_VERSION, generatedAt };
put(status, "ok", cleanBoolean(payload.ok));
put(status, "expectedCount", cleanCount(payload.expectedCount));
if (Array.isArray(payload.databases)) {
status.databases = payload.databases.filter(isPlainObject).slice(0, MAX_DATABASES).map(sanitizeDatabase);
}
if (Array.isArray(payload.files)) {
status.files = payload.files.filter(isPlainObject).slice(0, MAX_FILES).map(sanitizeFile);
}
put(status, "globals", sanitizeComponent(payload.globals, (out, value) => {
put(out, "sizeBytes", cleanCount(value.sizeBytes));
}));
put(status, "mirror", sanitizeComponent(payload.mirror, (out, value) => {
putNullable(out, "uploadedAt", value.uploadedAt, cleanTimestamp);
}));
put(status, "rotation", sanitizeComponent(payload.rotation, () => {}));
put(status, "retentionDays", sanitizeRetention(payload.retentionDays));
return { status };
}
// Returns { pull } or { error: <message> }. Strict: every field must be there
// and valid. Anything else in the body, a client `receivedAt` included, is
// dropped: the server stamps its own.
function validateBackupsPull(payload) {
if (!isPlainObject(payload)) return { error: "body must be a JSON object" };
if (!isIsoTimestamp(payload.pulledAt)) {
return { error: "pulledAt must be an ISO 8601 timestamp with an offset" };
}
if (cleanCount(payload.files) === undefined) {
return { error: "files must be a non-negative integer" };
}
if (typeof payload.ok !== "boolean") return { error: "ok must be a boolean" };
const pull = { pulledAt: payload.pulledAt, files: payload.files, ok: payload.ok };
if (payload.ok) {
if (payload.error !== undefined) return { error: "error is only allowed when ok is false" };
} else {
if (!PULL_ERROR_CODES.includes(payload.error)) {
return { error: `error must be one of ${PULL_ERROR_CODES.join(",")} when ok is false` };
}
pull.error = payload.error;
}
return { pull };
}
module.exports = {
sanitizeBackupsStatus,
validateBackupsPull,
BACKUPS_MAX_BODY_BYTES,
SCHEMA_VERSION,
MAX_DATABASES,
MAX_FILES,
VPS_ERROR_CODES,
FILE_ERROR_CODES,
PULL_ERROR_CODES,
};