// 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"]; // 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}$/; 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; } // 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; } 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: }, 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); } 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: }. 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, VPS_ERROR_CODES, PULL_ERROR_CODES, };