#!/usr/bin/env node /** * pb_migration_shareholder_certificates.mjs * * ONE-OFF, IDEMPOTENT schema migration that gives section 5 a place to file the * signed share certificates. * * --------------------------------------------------------------------------- * WHY A SECOND FILE SLOT * --------------------------------------------------------------------------- * A change in shareholding produces TWO pieces of paperwork, not one: * * 1. the special resolution, signed by the shareholders → signed_letter * 2. the NEW share certificates, also signed → signed_certificates * * The certificates are produced outside the app and uploaded once signed. A * single slot would mean the second upload silently destroyed the first — which * is exactly the failure the destination picker in section 4 exists to prevent. * * --------------------------------------------------------------------------- * WHY A MIGRATION INSTEAD OF RUNTIME ADMIN CALLS * --------------------------------------------------------------------------- * Editing a collection schema needs superuser rights. Doing that from a request * handler would put admin credentials in the hot path. This applies the change * ONCE, ahead of time; the running app then uses only record-level APIs. * * Re-running is safe. Run it as often as you like. * * --------------------------------------------------------------------------- * WHAT IT CHANGES (additive only — nothing existing is modified or removed) * --------------------------------------------------------------------------- * ysterbaard_resolutions + signed_certificates file (PDF, max 15 MB) * * --------------------------------------------------------------------------- * USAGE * --------------------------------------------------------------------------- * node scripts/pb_migration_shareholder_certificates.mjs # apply * node scripts/pb_migration_shareholder_certificates.mjs --check # report * * Credentials come from .env.local (PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD). */ import { readFileSync } from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ENV_FILE = process.env.ENV_FILE || path.join(HERE, "..", ".env.local"); const RESOLUTIONS_COL = "pbc_266170881"; // ysterbaard_resolutions const MAX_BYTES = 15 * 1024 * 1024; // --------------------------------------------------------------------------- // env // --------------------------------------------------------------------------- function loadEnv() { const cfg = {}; try { for (const line of readFileSync(ENV_FILE, "utf8").split(/\r?\n/)) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+)\s*$/); if (m) cfg[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); } } catch { throw new Error(`Cannot read ${ENV_FILE}`); } if (!cfg.PB_URL) throw new Error("PB_URL is not set in .env.local"); if (!cfg.PB_ADMIN_EMAIL || !cfg.PB_ADMIN_PASSWORD) { throw new Error("PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are not set in .env.local"); } return cfg; } // --------------------------------------------------------------------------- // http // --------------------------------------------------------------------------- async function auth(cfg) { const res = await fetch(`${cfg.PB_URL}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: cfg.PB_ADMIN_EMAIL, password: cfg.PB_ADMIN_PASSWORD, }), }); if (!res.ok) { throw new Error(`Admin auth failed: HTTP ${res.status} ${await res.text()}`); } const body = await res.json(); if (!body.token) throw new Error("Admin auth returned no token"); return body.token; } async function pbGet(token, url) { const res = await fetch(url, { headers: { Authorization: token } }); if (!res.ok) throw new Error(`GET ${url} failed: HTTP ${res.status} ${await res.text()}`); return res.json(); } // PATCH, not PUT: the PUT variants 404 on this PocketBase build. async function pbPatch(token, url, body) { const res = await fetch(url, { method: "PATCH", headers: { Authorization: token, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`PATCH ${url} failed: HTTP ${res.status} ${await res.text()}`); return res.json(); } function stripServerKeys(field) { const { id, system, hidden, ...rest } = field; return { ...rest, hidden: Boolean(hidden), system: false }; } /** * Add `field` to a collection if its name is not present. * Returns "added" | "present" | "would-add". */ async function ensureField({ token, base, colId, field, checkOnly, label }) { const url = `${base}/api/collections/${colId}`; const current = await pbGet(token, url); const fields = current.fields || []; if (fields.some((f) => f.name === field.name)) { console.log(` - ${label}: already present - skipped`); return "present"; } if (checkOnly) { console.log(` - ${label}: would be added`); return "would-add"; } const updated = await pbPatch(token, url, { ...current, fields: fields.map(stripServerKeys).concat(stripServerKeys(field)), }); if (!(updated.fields || []).some((f) => f.name === field.name)) { throw new Error(`${label}: PATCH succeeded but field is missing`); } console.log(` + ${label}: added`); return "added"; } // --------------------------------------------------------------------------- // field definition // --------------------------------------------------------------------------- function signedCertificatesField() { return { name: "signed_certificates", type: "file", required: false, // PocketBase validates MIME against this list. A scan or a phone photo // must be saved as PDF first; the upload route says so in plain English // rather than surfacing a raw PocketBase error. mimeTypes: ["application/pdf"], maxSelect: 1, maxSize: MAX_BYTES, // PDF is not previewable inline in the admin UI, and these are records the // operator opens through the app's own document viewer. preview: ["none"], }; } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- async function main() { const checkOnly = process.argv.includes("--check"); const cfg = loadEnv(); console.log(""); console.log("Shareholder certificates — signed_certificates slot"); console.log(` target : ${cfg.PB_URL}`); console.log(` mode : ${checkOnly ? "check only" : "apply"}`); console.log(""); const token = await auth(cfg); console.log("Applying"); await ensureField({ token, base: cfg.url ?? cfg.PB_URL, colId: RESOLUTIONS_COL, field: signedCertificatesField(), checkOnly, label: "resolutions.signed_certificates (file, PDF)", }); // ---- verify ---- console.log(""); console.log("Verification"); const res = await pbGet(token, `${cfg.PB_URL}/api/collections/${RESOLUTIONS_COL}`); const byName = Object.fromEntries((res.fields || []).map((f) => [f.name, f])); const cert = byName.signed_certificates; const signedLetter = byName.signed_letter; const generated = byName.generated_letter; console.log(` signed_certificates : ${cert ? `${cert.type}, ${cert.maxSelect} file` : "MISSING"}`); console.log(` signed_letter : ${signedLetter ? "present" : "MISSING"}`); console.log(` generated_letter : ${generated ? "present" : "MISSING"}`); const problems = []; if (!cert) { // In check mode this is the expected finding, not a failure — the field is // simply not there yet. Exiting non-zero would make `--check` unusable as // a pre-flight. if (checkOnly) { console.log(""); console.log("Check complete. Re-run without --check to apply."); console.log(""); return; } problems.push("signed_certificates missing"); } for (const name of ["signed_letter", "generated_letter"]) { if (!byName[name]) problems.push(`${name} LOST`); } console.log(""); if (problems.length) { console.error(`FAILED: ${problems.join("; ")}`); process.exitCode = 1; return; } console.log( checkOnly ? "Check complete. Re-run without --check to apply." : "Migration complete. The app now needs only record-level API access." ); console.log(""); } main().catch((err) => { console.error(""); console.error("Migration failed:", err.message); console.error(""); process.exitCode = 1; });