All checks were successful
CI / verification (pull_request) Successful in 35s
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>
204 lines
7.6 KiB
JavaScript
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,
|
|
};
|