Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
239 lines
No EOL
8.5 KiB
JavaScript
239 lines
No EOL
8.5 KiB
JavaScript
#!/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;
|
|
}); |