Remaining features
CI / ci (push) Canceled after 0s

Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
This commit is contained in:
Stefan Lazic committed 2026-10-17 23:10:18 +01:00
1 parent 17eee5b932
commit 68a62e02a4
116 files changed
+3089 -766

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ ENV NODE_ENV=production
# stacks/infra/compose.yaml's manager service (docker.sock).
RUN apk add --no-cache docker-cli docker-cli-compose
# 7-Zip for encrypted backups (`7zz a -mhe=on -p…`, see src/lib/sevenzip.ts).
# 7-Zip for backups (`7zz a`, plain 7z — see src/lib/sevenzip.ts).
# Alpine's package is named `7zip` and installs /usr/bin/7zz (checked
# against node:22-alpine, Alpine 3.24: `apk add 7zip` → 7-Zip 26.01).
RUN apk add --no-cache 7zip
+8 -7
View File
@@ -63,16 +63,17 @@ service. State lives next to `global-settings.json`, under
over; on boot, jobs left "running" are marked failed and their locks
released.
- **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's
*Backups* page and the global *Restore* page): an encrypted `.7z`
(`7zz`, AES-256, encrypted headers) of the database dump, media and
*Backups* page and the global *Restore* page): a plain `.7z`
(`7zz`, **not password-protected**) of the database dump, media and
config, in `STACKS_DIR/backups/<slug>/`. Written as `.partial`, verified
with `7zz t`, then renamed; the newest N (default 2, per business) are
kept. Safety backups (before an update or a replacing restore) and final
backups (before a delete) are kept separately and never evict them. One
passphrase protects everything: generated at the first enable / first
*Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`),
**shown once** — save it, a fresh server needs it to restore. Restore
works onto an existing business (after a safety backup) or a server that
backups (before a delete) are kept separately and never evict them.
Because there is no password, an archive holds the business's secrets in
the clear (its `.env`: database password, auth secret, SMTP password,
VAPID private key) — files are written owner-only (0600) and the
download route is behind the Manager login; keep any downloaded copy as
private as the server itself. Restore works onto an existing business (after a safety backup) or a server that
never had it; see `RESTORE_STEPS` in `src/lib/restore.ts`. A backup
whose last migration is newer than the app image's is refused.
- **Alerts** (`src/lib/health-watcher.ts`): per-business ok / degraded /
@@ -6,7 +6,7 @@ import { backupFilePath } from "@/lib/backup-store";
export const dynamic = "force-dynamic";
/** Streams a stored archive as a download — never buffered in memory, since archives can be large. The .7z is encrypted; the download itself exposes nothing without the passphrase. */
/** Streams a stored archive as a download — never buffered in memory, since archives can be large. The .7z is NOT password-protected and holds the business's secrets, so this route sits behind the Manager login. */
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string; file: string }> }) {
const { slug, file } = await params;
+1 -2
View File
@@ -6,8 +6,7 @@ export const dynamic = "force-dynamic";
/**
* Polled by the JobProgress component. Behind the Manager login like
* every route (see proxy.ts). A job record holds step names, a log tail
* and a result summary — never secrets (the restore passphrase only ever
* lives in the running job's closure).
* and a result summary — never secrets.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
+1 -4
View File
@@ -4,7 +4,6 @@ import path from "node:path";
import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { upsertEnvValues } from "@/lib/env-file";
import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
import { setIntentionallyStopped } from "@/lib/health-watcher";
import { fetchInstallationStatus } from "@/lib/installation-status";
import { NO_JOB, type JobStartState } from "@/lib/job-state";
@@ -55,10 +54,8 @@ export async function restartStackAction(slug: string): Promise<void> {
export async function updateStackAction(slug: string, backupFirst: boolean): Promise<JobStartState> {
await assertStack(slug);
try {
// "Back up first" needs a passphrase; if this is the very first backup anywhere, it is minted here and shown once.
const newPassphrase = backupFirst && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startUpdateJob(slug, backupFirst);
return { error: null, jobId, newPassphrase };
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the update." };
@@ -3,7 +3,6 @@
import { revalidatePath } from "next/cache";
import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings";
import { deleteBackup } from "@/lib/backup-store";
import { ensureBackupPassphrase, hasBackupPassphrase, rotateBackupPassphrase } from "@/lib/global-settings";
import { NO_JOB, type JobStartState } from "@/lib/job-state";
import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
import { startBackupJob } from "@/lib/operations";
@@ -16,8 +15,6 @@ async function assertBusiness(slug: string): Promise<void> {
export interface SaveBackupSettingsState {
error: string | null;
saved: boolean;
/** Plaintext passphrase — only in the one response that just generated it (first enable anywhere). */
newPassphrase: string | null;
}
export async function saveBackupSettingsAction(
@@ -35,26 +32,18 @@ export async function saveBackupSettingsAction(
keep: Number(formData.get("keep") ?? 2),
};
const problem = validateBackupSettingsInput(input);
if (problem) return { error: problem, saved: false, newPassphrase: null };
// Generated at the first enable, encrypted with the Manager's key, shown once.
let newPassphrase: string | null = null;
if (input.enabled) {
const ensured = await ensureBackupPassphrase();
if (ensured.created) newPassphrase = ensured.passphrase;
}
if (problem) return { error: problem, saved: false };
await saveBackupSettings(slug, input);
revalidatePath(`/dashboard/${slug}/backups`);
return { error: null, saved: true, newPassphrase };
return { error: null, saved: true };
}
export async function backupNowAction(slug: string): Promise<JobStartState> {
await assertBusiness(slug);
try {
const newPassphrase = (await hasBackupPassphrase()) ? null : (await ensureBackupPassphrase()).passphrase;
const { jobId } = await startBackupJob(slug, "regular");
return { error: null, jobId, newPassphrase };
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the backup." };
@@ -71,12 +60,3 @@ export async function deleteBackupAction(slug: string, fileName: string): Promis
return { error: error instanceof Error ? error.message : "Couldn't delete that backup." };
}
}
/** Replaces the passphrase used for NEW backups. Older archives still need the old one — the UI says so. Returns the new value once. */
export async function rotatePassphraseAction(): Promise<{ error: string | null; newPassphrase: string | null }> {
try {
return { error: null, newPassphrase: await rotateBackupPassphrase() };
} catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't generate a passphrase.", newPassphrase: null };
}
}
@@ -5,11 +5,9 @@ import { useRouter } from "next/navigation";
import { useActionState, useState, useTransition } from "react";
import type { BackupEntry } from "@/lib/backup-store";
import { JobProgress } from "../../job-progress";
import { PassphraseNotice } from "../../passphrase-notice";
import {
backupNowAction,
deleteBackupAction,
rotatePassphraseAction,
saveBackupSettingsAction,
type SaveBackupSettingsState,
} from "./actions";
@@ -27,7 +25,7 @@ interface SettingsView {
}
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const initialSaveState: SaveBackupSettingsState = { error: null, saved: false, newPassphrase: null };
const initialSaveState: SaveBackupSettingsState = { error: null, saved: false };
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
@@ -50,16 +48,12 @@ export function BackupsPanel({
slug,
settings,
backups,
hasPassphrase,
passphraseCreatedAt,
activeJobId,
timeZone,
}: {
slug: string;
settings: SettingsView;
backups: BackupEntry[];
hasPassphrase: boolean;
passphraseCreatedAt: string | null;
activeJobId: string | null;
timeZone: string;
}) {
@@ -70,8 +64,6 @@ export function BackupsPanel({
const [error, setError] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
const [shownPassphrase, setShownPassphrase] = useState<string | null>(null);
const passphraseToShow = shownPassphrase ?? saveState.newPassphrase;
const backupNow = () => {
setError(null);
@@ -81,7 +73,6 @@ export function BackupsPanel({
setError(result.error);
return;
}
if (result.newPassphrase) setShownPassphrase(result.newPassphrase);
setJobId(result.jobId);
setJobRunning(true);
});
@@ -97,25 +88,8 @@ export function BackupsPanel({
});
};
const rotate = () => {
if (
!window.confirm(
"Generate a NEW passphrase? New backups will use it. Existing backups keep the old one — you will need both to open all of them.",
)
) {
return;
}
startTransition(async () => {
const result = await rotatePassphraseAction();
if (result.error) setError(result.error);
else setShownPassphrase(result.newPassphrase);
});
};
return (
<div className="space-y-8">
{passphraseToShow && <PassphraseNotice passphrase={passphraseToShow} />}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Schedule</h2>
<form action={saveAction} className="space-y-4 rounded-md border border-line bg-surface p-5">
@@ -251,17 +225,12 @@ export function BackupsPanel({
</section>
<section className="space-y-2 text-sm text-muted">
<h2 className="text-lg font-semibold text-ink">Passphrase</h2>
<h2 className="text-lg font-semibold text-ink">Handle with care</h2>
<p>
{hasPassphrase
? `A passphrase protects every backup${passphraseCreatedAt ? ` (created ${new Date(passphraseCreatedAt).toLocaleDateString()})` : ""}. It is stored encrypted here and cannot be shown again — keep your saved copy; a fresh server needs it to restore.`
: "No passphrase yet — one is generated (and shown once) the first time you enable backups or press Back up now."}
Backups are not password-protected. Each one contains this business&apos;s secrets (its <span className="font-mono">.env</span>: database
password, sign-in secret, email password, push keys) as well as its data. They are stored owner-only on this server; if you download one,
keep it somewhere as private as the server itself.
</p>
{hasPassphrase && (
<button type="button" onClick={rotate} disabled={isPending} className="font-medium text-ink underline disabled:opacity-60">
Generate a new passphrase…
</button>
)}
</section>
</div>
);
@@ -3,7 +3,6 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { readBackupSettings } from "@/lib/backup-settings";
import { listBackups } from "@/lib/backup-store";
import { backupPassphraseCreatedAt, hasBackupPassphrase } from "@/lib/global-settings";
import { getActiveLock } from "@/lib/locks";
import { listBusinessStacks } from "@/lib/stacks";
import { BackupsPanel } from "./backups-panel";
@@ -17,11 +16,9 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug:
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
const [settings, backups, hasPassphrase, passphraseCreatedAt, lock] = await Promise.all([
const [settings, backups, lock] = await Promise.all([
readBackupSettings(slug),
listBackups(slug),
hasBackupPassphrase(),
backupPassphraseCreatedAt(),
getActiveLock(slug),
]);
@@ -56,8 +53,6 @@ export default async function BackupsPage({ params }: { params: Promise<{ slug:
lastMessage: settings.lastMessage,
}}
backups={backups}
hasPassphrase={hasPassphrase}
passphraseCreatedAt={passphraseCreatedAt}
activeJobId={lock?.jobId ?? null}
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
/>
@@ -2,7 +2,6 @@
import { useState, useTransition } from "react";
import { JobProgress } from "../job-progress";
import { PassphraseNotice } from "../passphrase-notice";
import {
fetchLogsAction,
restartStackAction,
@@ -19,7 +18,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
const [backupFirst, setBackupFirst] = useState(true);
const [jobId, setJobId] = useState<string | null>(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
// Quick actions are refused server-side while a job holds the business; the buttons just mirror that.
const busy = isPending || jobRunning;
@@ -44,7 +42,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
setError(result.error);
return;
}
setNewPassphrase(result.newPassphrase);
setJobId(result.jobId);
setJobRunning(true);
setUpdateOpen(false);
@@ -139,7 +136,6 @@ export function BusinessActions({ slug, activeJobId }: { slug: string; activeJob
{error}
</p>
)}
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
{logs !== null && (
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
@@ -1,6 +1,5 @@
"use server";
import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks";
import { startDeleteJob } from "@/lib/operations";
@@ -8,8 +7,6 @@ export interface DeleteBusinessState {
error: string | null;
/** Set once the delete job has started — the form then follows it with <JobProgress>. */
jobId: string | null;
/** Plaintext backup passphrase, only in the response that just generated it. */
newPassphrase: string | null;
}
/**
@@ -28,16 +25,15 @@ export async function deleteBusinessAction(
): Promise<DeleteBusinessState> {
const confirmation = String(formData.get("confirmation") ?? "");
if (confirmation !== slug) {
return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null, newPassphrase: null };
return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null };
}
const finalBackup = formData.get("finalBackup") === "on";
try {
const newPassphrase = finalBackup && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startDeleteJob(slug, finalBackup);
return { error: null, jobId, newPassphrase };
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return { error: error.message, jobId: null, newPassphrase: null };
return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null, newPassphrase: null };
if (error instanceof BusinessLockedError) return { error: error.message, jobId: null };
return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null };
}
}
@@ -3,10 +3,9 @@
import Link from "next/link";
import { useActionState, useState } from "react";
import { JobProgress } from "../../job-progress";
import { PassphraseNotice } from "../../passphrase-notice";
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
const initialState: DeleteBusinessState = { error: null, jobId: null, newPassphrase: null };
const initialState: DeleteBusinessState = { error: null, jobId: null };
export function DeleteBusinessForm({ slug }: { slug: string }) {
const boundAction = deleteBusinessAction.bind(null, slug);
@@ -17,7 +16,6 @@ export function DeleteBusinessForm({ slug }: { slug: string }) {
if (state.jobId) {
return (
<div className="space-y-4">
{state.newPassphrase && <PassphraseNotice passphrase={state.newPassphrase} />}
<JobProgress jobId={state.jobId} refreshOnFinish={false}>
<Link href="/dashboard" className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
Back to businesses
@@ -1,33 +0,0 @@
"use client";
import { useState } from "react";
/** The backup passphrase, shown exactly once (in the response that generated it). Nothing here persists it — the server keeps only an encrypted copy. */
export function PassphraseNotice({ passphrase }: { passphrase: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(passphrase);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<div role="alert" className="space-y-2 rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="font-semibold text-amber-900">Save this backup passphrase now — it won&apos;t be shown again.</p>
<p className="text-amber-900">
Every backup is encrypted with it. On a new server you&apos;ll need to type it to restore; without it the backups
cannot be opened by anyone.
</p>
<div className="flex flex-wrap items-center gap-3">
<code className="select-all rounded bg-white px-3 py-1.5 font-mono text-base font-semibold">{passphrase}</code>
<button type="button" onClick={copy} className="rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium">
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
);
}
+12 -22
View File
@@ -1,6 +1,5 @@
"use server";
import { ensureBackupPassphrase, hasBackupPassphrase, readBackupPassphrase } from "@/lib/global-settings";
import { BusinessLockedError } from "@/lib/locks";
import { startRestoreJob } from "@/lib/operations";
import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore";
@@ -30,17 +29,11 @@ export interface InspectRestoreState {
preview: RestorePreview | null;
}
async function passphraseFor(input: string): Promise<string> {
const passphrase = input.trim() || (await readBackupPassphrase()) || "";
if (!passphrase) throw new Error("Enter the backup passphrase (this server has no stored one).");
return passphrase;
}
/** Opens only the encrypted header (fast) and reports what restoring would do — nothing is changed. A wrong passphrase fails here. */
export async function inspectRestoreAction(sourceKey: string, passphraseInput: string): Promise<InspectRestoreState> {
/** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */
export async function inspectRestoreAction(sourceKey: string): Promise<InspectRestoreState> {
try {
const source = parseSourceKey(sourceKey);
const manifest = await readArchiveManifest(resolveArchivePath(source), await passphraseFor(passphraseInput));
const manifest = await readArchiveManifest(resolveArchivePath(source));
const plan = await planRestore(manifest);
return {
error: null,
@@ -57,11 +50,13 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s
},
};
} catch (error) {
// A wrong passphrase makes 7zz fail with a generic message; say so plainly instead of passing its output through.
// 7zz fails with a generic message on a damaged or foreign file; say so plainly instead of passing its output through.
const message = error instanceof Error ? error.message : "";
const looksLikePassphrase = /wrong password|can not open encrypted|data error|headers error|exited with code 2/i.test(message);
const looksUnreadable = /wrong password|can not open|data error|headers error|exited with code 2/i.test(message);
return {
error: looksLikePassphrase ? "Couldn't open the archive — wrong passphrase, or the file isn't a valid NextChair backup." : message || "Couldn't read that backup.",
error: looksUnreadable
? "Couldn't open that file — it isn't a valid NextChair backup (backups made by an older version with a password can't be opened here)."
: message || "Couldn't read that backup.",
preview: null,
};
}
@@ -70,20 +65,17 @@ export async function inspectRestoreAction(sourceKey: string, passphraseInput: s
export interface StartRestoreState {
error: string | null;
jobId: string | null;
newPassphrase: string | null;
}
export async function startRestoreAction(
sourceKey: string,
passphraseInput: string,
replace: boolean,
confirmSlug: string,
): Promise<StartRestoreState> {
const fail = (error: string): StartRestoreState => ({ error, jobId: null, newPassphrase: null });
const fail = (error: string): StartRestoreState => ({ error, jobId: null });
try {
const source = parseSourceKey(sourceKey);
const passphrase = await passphraseFor(passphraseInput);
const manifest = await readArchiveManifest(resolveArchivePath(source), passphrase);
const manifest = await readArchiveManifest(resolveArchivePath(source));
// Re-checked here, not trusted from the preview the browser holds.
const plan = await planRestore(manifest);
@@ -93,10 +85,8 @@ export async function startRestoreAction(
return fail(`"${manifest.slug}" already exists. Tick "replace" and type its slug to confirm overwriting it.`);
}
// Replacing takes a safety backup, which needs a stored passphrase; mint one (shown once) if this server has none yet.
const newPassphrase = plan.slugExists && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
const { jobId } = await startRestoreJob({ source, passphrase, replace });
return { error: null, jobId, newPassphrase };
const { jobId } = await startRestoreJob({ source, replace });
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return fail(error.message);
return fail(error instanceof Error ? error.message : "Couldn't start the restore.");
+2 -5
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { listAllBackups } from "@/lib/backup-store";
import { hasBackupPassphrase } from "@/lib/global-settings";
import { RestoreWizard } from "./restore-wizard";
export const metadata: Metadata = { title: "Restore" };
@@ -9,7 +8,7 @@ export const dynamic = "force-dynamic";
export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) {
const { slug, file } = await searchParams;
const [backups, hasPassphrase] = await Promise.all([listAllBackups(), hasBackupPassphrase()]);
const backups = await listAllBackups();
const preselected = slug && file ? `backup:${slug}:${file}` : null;
const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024;
@@ -23,8 +22,7 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom
<h1 className="mt-2 text-xl font-bold">Restore a business</h1>
<p className="text-sm text-muted">
Restores a backup onto the business it came from, or onto a fresh server where it never existed. Pick a stored
backup or upload a <span className="font-mono">.7z</span>, enter the passphrase, check what it contains, then
confirm.
backup or upload a <span className="font-mono">.7z</span>, check what it contains, then confirm.
</p>
</div>
<RestoreWizard
@@ -37,7 +35,6 @@ export default async function RestorePage({ searchParams }: { searchParams: Prom
sizeBytes: b.sizeBytes,
}))}
preselected={preselected}
hasStoredPassphrase={hasPassphrase}
maxUploadBytes={maxUploadBytes}
/>
</div>
@@ -3,7 +3,6 @@
import Link from "next/link";
import { useRef, useState, useTransition } from "react";
import { JobProgress } from "../job-progress";
import { PassphraseNotice } from "../passphrase-notice";
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
interface BackupOption {
@@ -24,12 +23,10 @@ function formatBytes(bytes: number): string {
export function RestoreWizard({
backups,
preselected,
hasStoredPassphrase,
maxUploadBytes,
}: {
backups: BackupOption[];
preselected: string | null;
hasStoredPassphrase: boolean;
maxUploadBytes: number;
}) {
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
@@ -37,14 +34,12 @@ export function RestoreWizard({
const [uploadKey, setUploadKey] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadName, setUploadName] = useState<string | null>(null);
const [passphrase, setPassphrase] = useState("");
const [preview, setPreview] = useState<RestorePreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [replace, setReplace] = useState(false);
const [confirmSlug, setConfirmSlug] = useState("");
const [jobId, setJobId] = useState<string | null>(null);
const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const xhrRef = useRef<XMLHttpRequest | null>(null);
@@ -91,7 +86,7 @@ export function RestoreWizard({
if (!sourceKey) return;
resetPreview();
startTransition(async () => {
const result = await inspectRestoreAction(sourceKey, passphrase);
const result = await inspectRestoreAction(sourceKey);
if (result.error) setError(result.error);
setPreview(result.preview);
});
@@ -101,12 +96,11 @@ export function RestoreWizard({
if (!sourceKey || !preview) return;
setError(null);
startTransition(async () => {
const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug);
const result = await startRestoreAction(sourceKey, replace, confirmSlug);
if (result.error) {
setError(result.error);
return;
}
setNewPassphrase(result.newPassphrase);
setRestoredSlug(preview.slug);
setJobId(result.jobId);
});
@@ -118,7 +112,6 @@ export function RestoreWizard({
if (jobId) {
return (
<div className="space-y-4">
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
<JobProgress jobId={jobId}>
{restoredSlug && (
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
@@ -199,28 +192,13 @@ export function RestoreWizard({
</fieldset>
<div className="space-y-2">
<label htmlFor="passphrase" className="block text-sm font-semibold">
2. Passphrase
</label>
<input
id="passphrase"
type="password"
autoComplete="off"
value={passphrase}
onChange={(e) => {
setPassphrase(e.target.value);
resetPreview();
}}
placeholder={hasStoredPassphrase ? "Leave blank to use this server's stored passphrase" : "Required on a fresh server"}
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
<button
type="button"
onClick={inspect}
disabled={isPending || !sourceKey || (!passphrase && !hasStoredPassphrase)}
disabled={isPending || !sourceKey}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending && !preview ? "Checking…" : "Check this backup"}
{isPending && !preview ? "Checking…" : "2. Check this backup"}
</button>
</div>
+13 -1
View File
@@ -2,6 +2,7 @@
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout";
import { applyOperatorAlertEmailToAllBusinesses, type OperatorRolloutResult } from "@/lib/operator-rollout";
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
import {
generateVapidKeys,
@@ -16,6 +17,7 @@ import {
resolvePushSettings,
saveEmailSettings,
saveGoogleCalendarSettings,
readOperatorSettings,
saveOperatorSettings,
savePushSubject,
saveSuperAdminSettings,
@@ -367,6 +369,8 @@ export async function reapplyGoogleCalendarSettingsAction(
export interface SaveOperatorSettingsState {
error: string | null;
saved: boolean;
/** Set when the alert address changed and was pushed to the businesses (each restarts to pick it up); null when nothing needed rolling out. */
rollout?: OperatorRolloutResult[] | null;
}
export async function saveOperatorSettingsAction(
@@ -386,7 +390,9 @@ export async function saveOperatorSettingsAction(
return { error: "Health check interval must be between 1 and 60 minutes.", saved: false };
}
let previousAlertEmail = "";
try {
previousAlertEmail = (await readOperatorSettings()).alertEmail;
await saveOperatorSettings({
alertEmail,
alertOnHealth: formData.get("alertOnHealth") === "on",
@@ -399,7 +405,13 @@ export async function saveOperatorSettingsAction(
} catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false };
}
return { error: null, saved: true };
// Businesses read the alert address from their own .env, so a changed address has to be pushed out
// (and each restarted once). An unchanged one isn't touched at all.
let rollout: OperatorRolloutResult[] | null = null;
if (alertEmail !== previousAlertEmail) rollout = await applyOperatorAlertEmailToAllBusinesses(alertEmail);
return { error: null, saved: true, rollout };
}
export interface SendTestAlertState {
@@ -109,6 +109,18 @@ export function OperatorSettingsForm({ initial, status }: { initial: OperatorSet
</p>
)}
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
{saveState.rollout && saveState.rollout.length > 0 && (
<div className="text-sm">
<p className="text-muted">The alert address was sent to your businesses so they can email you about server errors:</p>
<ul className="mt-1 space-y-0.5">
{saveState.rollout.map((r) => (
<li key={r.slug} className={r.ok ? "text-green-700" : "text-danger"}>
{r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`}
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
+5 -16
View File
@@ -21,15 +21,12 @@ vi.mock("@/lib/sevenzip", () => ({
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
}));
const readBackupPassphraseMock = vi.fn();
vi.mock("@/lib/global-settings", () => ({ readBackupPassphrase: () => readBackupPassphraseMock() }));
import { performBackup } from "../backup";
import { backupFileName, listBackups } from "../backup-store";
import { saveBackupSettings } from "../backup-settings";
import { parseManifest } from "../backup-manifest";
const PASSPHRASE = "test-passphrase-123";
const ENV = [
'BUSINESS_NAME="Acme Barbers"',
'DOMAIN="acme.example.com"',
@@ -56,7 +53,6 @@ describe("performBackup", () => {
await writeFile(path.join(stacksDir, "infra", "sites", "acme.caddy"), "acme.example.com {\n}\n");
containerExistsMock.mockReset().mockResolvedValue(true);
readBackupPassphraseMock.mockReset().mockResolvedValue(PASSPHRASE);
runCaptureMock.mockReset().mockResolvedValue({ stdout: "20261017180000_add_business_holidays\n", stderr: "" });
runToFileMock.mockReset().mockImplementation(async (spec: CommandSpec, file: string) => {
const isDump = spec.args.includes("mysqldump");
@@ -103,16 +99,16 @@ describe("performBackup", () => {
slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
kind: "regular", lastMigration: "20261017180000_add_business_holidays", hasCaddySite: true, createdAt: now.toISOString(),
});
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the encrypted archive
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the archive
// 4. encrypted with the stored passphrase, written as .partial first
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; passphrase: string; entries: string[] };
expect(create.passphrase).toBe(PASSPHRASE);
// 4. a plain archive (no password), written as .partial first
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; entries: string[] };
expect(create).not.toHaveProperty("passphrase");
expect(create.archivePath.endsWith(`${backupFileName("acme", "regular", now)}.partial`)).toBe(true);
expect(create.entries).toEqual(["manifest.json", "db.sql", "media.tar", "config"]);
// 5. verified (7zz t) the .partial, THEN renamed
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath, PASSPHRASE);
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath);
const finalPath = create.archivePath.replace(/\.partial$/, "");
expect((await stat(finalPath)).isFile()).toBe(true);
expect((await stat(finalPath)).mode & 0o777).toBe(0o600);
@@ -121,7 +117,6 @@ describe("performBackup", () => {
// no staging leftovers, and nothing secret in the log
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
expect(lines.join("\n")).not.toContain(PASSPHRASE);
expect(lines.join("\n")).not.toContain("abc123");
});
@@ -187,12 +182,6 @@ describe("performBackup", () => {
expect(names.filter((n) => n.includes("__safety"))).toHaveLength(2);
});
it("fails clearly when no passphrase exists yet", async () => {
readBackupPassphraseMock.mockResolvedValue(null);
await expect(performBackup("acme", "regular", logs().ctx)).rejects.toThrow(/No backup passphrase/);
expect(runToFileMock).not.toHaveBeenCalled();
});
it("emits job steps in order when given a step wrapper", async () => {
const order: string[] = [];
await performBackup("acme", "regular", { log: () => undefined, step: async (id, fn) => (order.push(id), fn()) });
@@ -84,39 +84,40 @@ describe("command construction", () => {
});
describe("7zz command construction", () => {
const passphrase = "correct-horse-battery";
it("create: 7z, encrypted headers, passphrase, listed entries, run from the staging dir", () => {
it("create: plain 7z (no password), listed entries, run from the staging dir", () => {
const spec = sevenZipCreateCommand({
archivePath: "/stacks/backups/acme/x.7z.partial",
cwd: "/stacks/manager/tmp/s1",
entries: ["manifest.json", "db.sql", "media.tar", "config"],
passphrase,
});
expect(spec.command).toBe("7zz");
expect(spec.cwd).toBe("/stacks/manager/tmp/s1");
expect(spec.args).toEqual([
"a", "-t7z", "-mx=3", "-mhe=on", `-p${passphrase}`, "-y", "-bd",
"a", "-t7z", "-mx=3", "-y", "-bd",
"/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config",
]);
});
it("test and stdout-extract commands", () => {
expect(sevenZipTestCommand("/a.7z", passphrase).args).toEqual(["t", "-t7z", `-p${passphrase}`, "-y", "-bd", "/a.7z"]);
expect(sevenZipExtractToStdoutCommand("/a.7z", passphrase, "db.sql").args).toEqual([
"x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
]);
it("never asks 7zz for encryption or a password", () => {
const all = [
...sevenZipCreateCommand({ archivePath: "/a.7z", cwd: "/tmp", entries: ["x"] }).args,
...sevenZipTestCommand("/a.7z").args,
...sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args,
];
expect(all.some((arg) => arg.startsWith("-p") || arg.startsWith("-mhe"))).toBe(false);
});
it("refuses a short passphrase (an empty -p would hang on a prompt)", () => {
expect(() => sevenZipTestCommand("/a.7z", "")).toThrow(/at least 8/);
expect(() => sevenZipTestCommand("/a.7z", "short")).toThrow(/at least 8/);
it("test and stdout-extract commands", () => {
expect(sevenZipTestCommand("/a.7z").args).toEqual(["t", "-t7z", "-y", "-bd", "/a.7z"]);
expect(sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args).toEqual([
"x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
]);
});
it("SEVENZIP_BIN overrides the binary", () => {
process.env.SEVENZIP_BIN = "/opt/7zz";
try {
expect(sevenZipTestCommand("/a.7z", passphrase).command).toBe("/opt/7zz");
expect(sevenZipTestCommand("/a.7z").command).toBe("/opt/7zz");
} finally {
delete process.env.SEVENZIP_BIN;
}
@@ -11,9 +11,7 @@ const waitForHealthMock = vi.fn();
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
const performBackupMock = vi.fn();
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] }));
const readBackupPassphraseMock = vi.fn();
vi.mock("@/lib/global-settings", () => ({
readBackupPassphrase: () => readBackupPassphraseMock(),
readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })),
}));
vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) }));
@@ -35,7 +33,6 @@ describe("Update and Delete jobs", () => {
calls.length = 0;
waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined));
performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` }));
readBackupPassphraseMock.mockReset().mockResolvedValue("a-stored-passphrase");
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
@@ -0,0 +1,79 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: vi.fn(),
runMigrations: vi.fn(),
}));
describe("applyOperatorAlertEmailToAllBusinesses", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operator-rollout-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("writes the alert address into each business and restarts it", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("acme", 'BUSINESS_NAME="Acme"\nDOMAIN="acme.example.com"\n');
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
expect(results).toEqual([{ slug: "acme", ok: true }]);
expect(await readFile(path.join(stacksDir, "acme", ".env"), "utf8")).toContain(
'OPERATOR_ALERT_EMAIL="ops@example.com"',
);
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), {
forceRecreate: true,
});
});
it("leaves a business that already has this address alone — no rewrite, no restart", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("acme", 'BUSINESS_NAME="Acme"\nOPERATOR_ALERT_EMAIL="ops@example.com"\n');
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
expect(results).toEqual([{ slug: "acme", ok: true, unchanged: true }]);
expect(composeUpMock).not.toHaveBeenCalled();
});
it("an empty address turns alerts off, and one failing business doesn't stop the rest", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("alpha", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
await createStack("beta", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
composeUpMock.mockRejectedValueOnce(new Error("docker unavailable"));
const results = await applyOperatorAlertEmailToAllBusinesses("");
expect(results).toHaveLength(2);
expect(results.filter((r) => r.ok)).toHaveLength(1);
expect(results.find((r) => !r.ok)?.error).toContain("docker unavailable");
expect(await readFile(path.join(stacksDir, "beta", ".env"), "utf8")).toContain(
'OPERATOR_ALERT_EMAIL=""',
);
});
});
+17 -19
View File
@@ -45,7 +45,6 @@ vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBack
import { performRestore, RESTORE_STEPS } from "../restore";
const PASSPHRASE = "restore-passphrase-1";
const DB_PASSWORD = "f".repeat(32);
const ARCHIVE = "/stacks/backups/acme/acme__20261017T030000Z__regular.7z";
const BACKED_UP_ENV = ['BUSINESS_NAME="Acme Barbers"', 'DOMAIN="acme.example.com"', 'DATABASE_NAME="acme"', 'DATABASE_USER="acme"', `DATABASE_PASSWORD="${DB_PASSWORD}"`, 'AUTH_SECRET="s3cret-from-backup"'].join("\n") + "\n";
@@ -71,7 +70,7 @@ describe("performRestore", () => {
recreateDatabaseMock.mockReset().mockImplementation(async () => record("recreateDatabase"));
runPipelineMock.mockReset().mockImplementation(async (source: CommandSpec) => record(`pipeline:${source.args.at(-1)}`));
sevenZipTestMock.mockReset().mockImplementation(async () => record("7zz-test"));
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, _p: string, member: string) => {
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, member: string) => {
record(`read:${member}`);
return member === "manifest.json" ? manifest() : BACKED_UP_ENV;
});
@@ -111,7 +110,7 @@ describe("performRestore", () => {
it("fresh server: runs the steps in the planned order and the side effects in the planned order", async () => {
const { ctx, steps } = makeCtx();
const result = await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx });
const result = await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
expect(steps).toEqual([
"step:verify", "step:check", "skip:safety", "skip:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
@@ -133,7 +132,7 @@ describe("performRestore", () => {
});
it("recreates the DB with the password FROM THE BACKED-UP .env, and writes that .env verbatim (0600) plus a regenerated compose.yaml", async () => {
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx });
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
expect(recreateDatabaseMock).toHaveBeenCalledWith("acme", "acme", DB_PASSWORD);
const envPath = path.join(stacksDir, "acme", ".env");
@@ -145,11 +144,11 @@ describe("performRestore", () => {
});
it("builds the import and media pipelines as argument arrays: 7zz -so → docker, no password in argv", async () => {
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx });
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
const [dumpSource, dumpSink] = runPipelineMock.mock.calls[0] as [CommandSpec, CommandSpec];
expect(dumpSource.command).toBe("7zz");
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", `-p${PASSPHRASE}`, "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
expect(dumpSink).toMatchObject({ command: "docker" });
expect(dumpSink.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
expect(dumpSink.args.join(" ")).not.toContain("root-pw");
@@ -163,7 +162,7 @@ describe("performRestore", () => {
it("existing business + replace: safety backup FIRST, then compose down (with volumes), then everything else", async () => {
await existingStack();
const { ctx, steps } = makeCtx();
const result = await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: true, ctx });
const result = await performRestore({ archivePath: ARCHIVE, replace: true, ctx });
expect(steps).toEqual([
"step:verify", "step:check", "step:safety", "step:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
@@ -179,7 +178,7 @@ describe("performRestore", () => {
it("existing business without 'replace': refuses before touching anything", async () => {
await existingStack();
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
expect(performBackupMock).not.toHaveBeenCalled();
expect(recreateDatabaseMock).not.toHaveBeenCalled();
expect(calls.some((c) => c.startsWith("composeDown"))).toBe(false);
@@ -187,38 +186,37 @@ describe("performRestore", () => {
it("domain conflict: refuses before any destructive step", async () => {
assertDomainAvailableMock.mockRejectedValue(new Error('Domain "acme.example.com" is already in use by "other".'));
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
expect(recreateDatabaseMock).not.toHaveBeenCalled();
expect(runPipelineMock).not.toHaveBeenCalled();
});
it("version guard: a backup from a newer schema than the image is refused before any change", async () => {
sevenZipReadMemberMock.mockImplementation(async (_a: string, _p: string, member: string) =>
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV,
);
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
expect(recreateDatabaseMock).not.toHaveBeenCalled();
});
it("refuses an archive whose .env disagrees with its manifest", async () => {
sevenZipReadMemberMock.mockImplementation(async (_a: string, _p: string, member: string) =>
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'),
);
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
expect(recreateDatabaseMock).not.toHaveBeenCalled();
});
it("a bad archive or wrong passphrase fails in the first step", async () => {
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Wrong password"));
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Wrong password/);
it("a bad or corrupt archive fails in the first step", async () => {
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data error"));
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Data error/);
expect(assertDomainAvailableMock).not.toHaveBeenCalled();
});
it("never logs the passphrase or any .env secret", async () => {
it("never logs any .env secret", async () => {
const { ctx, lines } = makeCtx();
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx });
await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
const all = lines.join("\n");
expect(all).not.toContain(PASSPHRASE);
expect(all).not.toContain(DB_PASSWORD);
expect(all).not.toContain("s3cret-from-backup");
});
+3 -10
View File
@@ -6,7 +6,6 @@ import { backupFileName, ensureBackupDir, pruneBackups, removePartialFiles, SAFE
import { containerExists } from "./docker";
import { mediaCopyOutCommand, mediaVolumeTarCommand, mysqlQueryCommand, mysqldumpCommand } from "./docker-commands";
import { readEnvFile, readEnvValue } from "./env-file";
import { readBackupPassphrase } from "./global-settings";
import { stagingRoot } from "./manager-paths";
import { runCapture, runToFile } from "./process";
import { randomHex } from "./random";
@@ -93,11 +92,6 @@ export async function performBackup(slug: string, kind: BackupKind, ctx: BackupR
const domain = readEnvValue(envContent, "DOMAIN");
if (!databaseName || !databaseUser || !domain) throw new Error(`"${slug}"'s .env is missing DATABASE_NAME/DATABASE_USER/DOMAIN.`);
const passphrase = await readBackupPassphrase();
if (!passphrase) {
throw new Error("No backup passphrase exists yet. Enable backups for a business (or use Back up now) from the Backups page so one is generated and shown to you.");
}
const outDir = await ensureBackupDir(slug);
await removePartialFiles(slug);
const finalName = backupFileName(slug, kind, now);
@@ -157,24 +151,23 @@ export async function performBackup(slug: string, kind: BackupKind, ctx: BackupR
});
await runStep(ctx, "archive", async () => {
ctx.log("Encrypting and compressing…");
ctx.log("Compressing…");
await rm(partialPath, { force: true });
await sevenZipCreate({
archivePath: partialPath,
cwd: stagingDir,
entries: [BACKUP_MEMBERS.manifest, BACKUP_MEMBERS.database, BACKUP_MEMBERS.media, "config"],
passphrase,
});
});
await runStep(ctx, "verify", async () => {
ctx.log("Verifying the archive (7zz t)…");
await sevenZipTest(partialPath, passphrase);
await sevenZipTest(partialPath);
});
return await runStep(ctx, "finish", async () => {
await rename(partialPath, finalPath);
await chmod(finalPath, 0o600); // 7zz honours the umask; the archive (encrypted or not) should be owner-only
await chmod(finalPath, 0o600); // 7zz honours the umask; the archive holds the business's secrets in the clear, so it must be owner-only
const sizeBytes = (await stat(finalPath)).size;
let pruned: string[] = [];
if (kind === "regular") {
+3
View File
@@ -109,6 +109,8 @@ export interface BusinessEnvValues {
superAdmin?: BusinessEnvSuperAdminValues;
/** Global platform Google Calendar OAuth client (see src/lib/global-settings.ts) — same pattern as `email`/`push` above. Omitted falls back to today's blank defaults. */
googleCalendar?: BusinessEnvGoogleCalendarValues;
/** The operator's alert address (Settings > Monitoring) — the app emails unhandled server errors here. Omitted or blank leaves app-side alerts off. */
operatorAlertEmail?: string;
}
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
@@ -167,6 +169,7 @@ GOOGLE_CALENDAR_CLIENT_SECRET=""`;
# screen) — the app itself doesn't read it; its own business name lives
# in the database, set via /install.
BUSINESS_NAME="${values.name}"
OPERATOR_ALERT_EMAIL="${values.operatorAlertEmail ?? ""}"
DOMAIN="${values.domain}"
DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306"
+1 -59
View File
@@ -2,7 +2,6 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import webpush from "web-push";
import { withMutex } from "./singleton";
import { stacksRoot } from "./stacks";
/**
@@ -89,19 +88,12 @@ export const DEFAULT_OPERATOR_SETTINGS: OperatorSettings = {
healthIntervalMinutes: 3,
};
/** Persisted shape — the passphrase only ever holds the encrypted envelope. One passphrase protects every business's backups, so a single value is enough to restore on a fresh server. */
interface StoredBackupSettings {
encryptedPassphrase: EncryptedPayload | null;
createdAt: string | null;
}
interface StoredGlobalSettings {
email: StoredEmailSettings | null;
push: StoredPushSettings | null;
superAdmin: SuperAdminSettings | null;
googleCalendar: StoredGoogleCalendarSettings | null;
operator: OperatorSettings | null;
backups: StoredBackupSettings | null;
}
export interface LastTestResult {
@@ -194,11 +186,10 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
superAdmin: parsed.superAdmin ?? null,
googleCalendar: parsed.googleCalendar ?? null,
operator: parsed.operator ?? null,
backups: parsed.backups ?? null,
};
} catch {
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null, backups: null };
return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null };
}
}
@@ -562,52 +553,3 @@ export async function saveOperatorSettings(input: OperatorSettings): Promise<Ope
await writeStoredSettings({ ...current, operator: next });
return next;
}
// ── Backup passphrase ────────────────────────────────────────────────────
export async function hasBackupPassphrase(): Promise<boolean> {
const { backups } = await readStoredSettings();
return Boolean(backups?.encryptedPassphrase);
}
/** Server-side only — the decrypted passphrase used for scheduled/manual archives. Null until one has been generated. */
export async function readBackupPassphrase(): Promise<string | null> {
const { backups } = await readStoredSettings();
return backups?.encryptedPassphrase ? decrypt(backups.encryptedPassphrase) : null;
}
export async function backupPassphraseCreatedAt(): Promise<string | null> {
const { backups } = await readStoredSettings();
return backups?.createdAt ?? null;
}
/**
* Returns the stored passphrase, generating (and encrypting) one on first
* use. `created` is true only on that first call — the one moment the UI
* shows the plaintext to the operator so they can save it somewhere safe;
* afterwards it is never displayed again (a fresh server needs it typed
* in at restore time).
*/
export function ensureBackupPassphrase(): Promise<{ passphrase: string; created: boolean }> {
// Mutex: a scheduled backup and a manual one racing on a fresh install must not each mint a different passphrase.
return withMutex("backup-passphrase", async () => {
const existing = await readBackupPassphrase();
if (existing) return { passphrase: existing, created: false };
return { passphrase: await storeNewBackupPassphrase(), created: true };
});
}
/** Replaces the passphrase (older archives still need the old one). Returns the new plaintext, shown once. */
export async function rotateBackupPassphrase(): Promise<string> {
return storeNewBackupPassphrase();
}
async function storeNewBackupPassphrase(): Promise<string> {
const passphrase = randomBytes(18).toString("base64url");
const current = await readStoredSettings();
await writeStoredSettings({
...current,
backups: { encryptedPassphrase: encrypt(passphrase), createdAt: new Date().toISOString() },
});
return passphrase;
}
+1 -3
View File
@@ -2,8 +2,6 @@
export interface JobStartState {
error: string | null;
jobId: string | null;
/** Plaintext backup passphrase — present only in the single response that just generated it. Shown once, never stored in a job record. */
newPassphrase: string | null;
}
export const NO_JOB: JobStartState = { error: null, jobId: null, newPassphrase: null };
export const NO_JOB: JobStartState = { error: null, jobId: null };
+4 -17
View File
@@ -2,7 +2,7 @@ import { performBackup, BACKUP_STEPS } from "./backup";
import type { BackupKind } from "./backup-manifest";
import { recordBackupOutcome } from "./backup-settings";
import { composeUp } from "./docker";
import { readBackupPassphrase, readOperatorSettings } from "./global-settings";
import { readOperatorSettings } from "./global-settings";
import { setIntentionallyStopped } from "./health-watcher";
import { waitForHealth } from "./health";
import { startJob, type JobContext, type StartedJob } from "./jobs";
@@ -19,12 +19,6 @@ import { isValidSlug, stackDir, stackExists } from "./stacks";
* here is awaited by a request.
*/
async function requirePassphrase(): Promise<string> {
const passphrase = await readBackupPassphrase();
if (!passphrase) throw new Error("No backup passphrase exists yet — open a business's Backups page first so one is generated.");
return passphrase;
}
async function alertBackupFailure(slug: string, message: string): Promise<void> {
try {
const settings = await readOperatorSettings();
@@ -37,7 +31,6 @@ async function alertBackupFailure(slug: string, message: string): Promise<void>
export async function startBackupJob(slug: string, kind: BackupKind = "regular"): Promise<StartedJob> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
await requirePassphrase();
return startJob({
kind: "backup",
@@ -64,7 +57,6 @@ export async function startBackupJob(slug: string, kind: BackupKind = "regular")
/** Recreates the container against the shared image tag; optionally takes a safety backup first. Rebuilding the image stays a manual step. */
export async function startUpdateJob(slug: string, backupFirst: boolean): Promise<StartedJob> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
if (backupFirst) await requirePassphrase();
return startJob({
kind: "update",
@@ -107,7 +99,6 @@ export async function startUpdateJob(slug: string, backupFirst: boolean): Promis
/** Takes an optional final backup, then removes the business. If the backup fails the business is left untouched. */
export async function startDeleteJob(slug: string, finalBackup: boolean): Promise<StartedJob> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
if (finalBackup) await requirePassphrase();
return startJob({
kind: "delete",
@@ -133,18 +124,14 @@ export async function startDeleteJob(slug: string, finalBackup: boolean): Promis
export interface RestoreJobInput {
source: RestoreSource;
/** Blank → the stored passphrase. */
passphrase: string;
replace: boolean;
}
export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJob> {
const passphrase = input.passphrase.trim() || (await readBackupPassphrase()) || "";
if (!passphrase) throw new Error("Enter the backup passphrase.");
const archivePath = resolveArchivePath(input.source);
// The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only the encrypted header is opened).
const manifest = await readArchiveManifest(archivePath, passphrase);
// The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only that one file is read).
const manifest = await readArchiveManifest(archivePath);
return startJob({
kind: "restore",
@@ -152,7 +139,7 @@ export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJo
title: `Restore ${manifest.slug}`,
steps: [...RESTORE_STEPS],
run: async (ctx) => {
const result = await performRestore({ archivePath, passphrase, replace: input.replace, ctx });
const result = await performRestore({ archivePath, replace: input.replace, ctx });
await setIntentionallyStopped(result.slug, false);
return { slug: result.slug, domain: result.domain, safetyBackup: result.safetyBackup };
},
+51
View File
@@ -0,0 +1,51 @@
import path from "node:path";
import { composeUp } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { listBusinessStacks, stackDir } from "./stacks";
export interface OperatorRolloutResult {
slug: string;
ok: boolean;
/** True when the business already had this address, so nothing was touched and it wasn't restarted. */
unchanged?: boolean;
error?: string;
}
/**
* Gives every business the operator's alert address as OPERATOR_ALERT_EMAIL, so
* the app itself can email an unhandled server error (see the main app's
* src/instrumentation.ts). Same surgical `upsertEnvValues` + force-recreate as
* the other rollouts — but a business that already has this exact value is
* skipped, because restarting every business just to re-write what's there
* would be a pointless interruption. An empty address turns the app-side
* alerts off. Sequential, like the other rollouts.
*/
export async function applyOperatorAlertEmailToAllBusinesses(
alertEmail: string,
): Promise<OperatorRolloutResult[]> {
const businesses = await listBusinessStacks();
const results: OperatorRolloutResult[] = [];
for (const business of businesses) {
try {
const envPath = path.join(stackDir(business.slug), ".env");
const current =
readEnvValue((await readEnvFile(envPath)) ?? "", "OPERATOR_ALERT_EMAIL") ?? "";
if (current === alertEmail) {
results.push({ slug: business.slug, ok: true, unchanged: true });
continue;
}
await upsertEnvValues(envPath, { OPERATOR_ALERT_EMAIL: alertEmail });
await composeUp(stackDir(business.slug), { forceRecreate: true });
results.push({ slug: business.slug, ok: true });
} catch (error) {
results.push({
slug: business.slug,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { pipeline } from "node:stream/promises";
* never execFile, whose maxBuffer would kill a large dump mid-stream.
*
* Commands are always `{ command, args[] }` (never a shell string), and
* secrets travel in `env` (e.g. MYSQL_PWD) or the 7zz passphrase arg —
* secrets travel in `env` (e.g. MYSQL_PWD) —
* never in anything this module logs: errors carry only the command's
* basename and its stderr tail.
*/
+2
View File
@@ -9,6 +9,7 @@ import {
isGoogleCalendarConfigComplete,
isPushConfigComplete,
isSuperAdminConfigComplete,
readOperatorSettings,
readSuperAdminSettings,
resolveEmailSettings,
resolveGoogleCalendarSettings,
@@ -231,6 +232,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
push,
superAdmin,
googleCalendar,
operatorAlertEmail: (await readOperatorSettings()).alertEmail,
}),
"utf8",
);
+13 -14
View File
@@ -30,7 +30,7 @@ import { stackDir, stackExists } from "./stacks";
*/
export const RESTORE_STEPS = [
{ id: "verify", label: "Verify the archive and passphrase" },
{ id: "verify", label: "Verify the archive" },
{ id: "check", label: "Check for conflicts and schema version" },
{ id: "safety", label: "Take a safety backup of the current business" },
{ id: "stop", label: "Stop the current stack" },
@@ -61,15 +61,15 @@ export interface RestorePlan {
schema: { ok: true } | { ok: false; reason: string };
}
/** Fast: opens only the (encrypted) header and reads manifest.json. A wrong passphrase fails here. Does not verify the payload. */
export async function readArchiveManifest(archivePath: string, passphrase: string): Promise<BackupManifest> {
return parseManifest(await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.manifest));
/** Fast: reads manifest.json from the archive. Does not verify the payload. */
export async function readArchiveManifest(archivePath: string): Promise<BackupManifest> {
return parseManifest(await sevenZipReadMember(archivePath, BACKUP_MEMBERS.manifest));
}
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive or wrong passphrase. */
export async function inspectArchive(archivePath: string, passphrase: string): Promise<BackupManifest> {
await sevenZipTest(archivePath, passphrase);
return readArchiveManifest(archivePath, passphrase);
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive. */
export async function inspectArchive(archivePath: string): Promise<BackupManifest> {
await sevenZipTest(archivePath);
return readArchiveManifest(archivePath);
}
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */
@@ -104,17 +104,16 @@ export interface RestoreResult {
export async function performRestore(params: {
archivePath: string;
passphrase: string;
/** Operator confirmed overwriting an existing business with the same slug. */
replace: boolean;
ctx: RestoreContext;
}): Promise<RestoreResult> {
const { archivePath, passphrase, replace, ctx } = params;
const { archivePath, replace, ctx } = params;
const { manifest, envContent } = await ctx.step("verify", async () => {
ctx.log("Testing the archive (7zz t)…");
const parsed = await inspectArchive(archivePath, passphrase);
const env = await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.env);
const parsed = await inspectArchive(archivePath);
const env = await sevenZipReadMember(archivePath, BACKUP_MEMBERS.env);
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
// The .env inside the archive must agree with the manifest — the
@@ -169,7 +168,7 @@ export async function performRestore(params: {
await ctx.step("import", async () => {
ctx.log("Importing db.sql (streamed from the archive)…");
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
});
await ctx.step("config", async () => {
@@ -183,7 +182,7 @@ export async function performRestore(params: {
await ctx.step("media", async () => {
// Create (don't start) the container so the named volume exists with compose's labels before it's filled.
await composeCreate(dir);
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
ctx.log("Media restored and handed to uid 1001.");
});
+18 -25
View File
@@ -2,55 +2,48 @@ import { runCapture, runQuiet, type CommandSpec } from "./process";
/**
* 7-Zip (`7zz`, from Alpine's `7zip` package — see Dockerfile) wrappers.
* Archives are 7z with AES-256 and encrypted headers (-mhe=on), so even
* the file names inside are hidden without the passphrase.
* Archives are plain (unencrypted) 7z: the operator decided backups don't
* need a password. That means an archive contains the business's secrets in
* the clear (its .env: database password, auth secret, SMTP password, VAPID
* private key) — which is why files are written owner-only (0600) and the
* download route is behind the Manager login. Treat a downloaded copy like
* the server's own .env files.
*
* The passphrase is passed as `-p<passphrase>` (7zz has no safer
* non-interactive channel). It is generated hex/base64url by the Manager
* or typed by the operator, and — like every command here — runs as an
* argument array, never through a shell.
* Every command here runs as an argument array, never through a shell.
*/
function sevenZipBinary(): string {
return process.env.SEVENZIP_BIN ?? "7zz";
}
function assertPassphrase(passphrase: string): void {
// An empty `-p` would make 7zz prompt on a terminal that doesn't exist.
if (passphrase.length < 8) throw new Error("The backup passphrase must be at least 8 characters.");
}
export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[]; passphrase: string }): CommandSpec {
assertPassphrase(params.passphrase);
export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[] }): CommandSpec {
return {
command: sevenZipBinary(),
args: ["a", "-t7z", "-mx=3", "-mhe=on", `-p${params.passphrase}`, "-y", "-bd", params.archivePath, ...params.entries],
args: ["a", "-t7z", "-mx=3", "-y", "-bd", params.archivePath, ...params.entries],
cwd: params.cwd,
};
}
export function sevenZipTestCommand(archivePath: string, passphrase: string): CommandSpec {
assertPassphrase(passphrase);
return { command: sevenZipBinary(), args: ["t", "-t7z", `-p${passphrase}`, "-y", "-bd", archivePath] };
export function sevenZipTestCommand(archivePath: string): CommandSpec {
return { command: sevenZipBinary(), args: ["t", "-t7z", "-y", "-bd", archivePath] };
}
/** Streams one archive member to stdout (`-so`) — for piping a dump/tar straight into docker without unpacking to disk. */
export function sevenZipExtractToStdoutCommand(archivePath: string, passphrase: string, member: string): CommandSpec {
assertPassphrase(passphrase);
return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
export function sevenZipExtractToStdoutCommand(archivePath: string, member: string): CommandSpec {
return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
}
export async function sevenZipCreate(params: Parameters<typeof sevenZipCreateCommand>[0]): Promise<void> {
await runQuiet(sevenZipCreateCommand(params));
}
/** Throws if the archive is corrupt or the passphrase is wrong. */
export async function sevenZipTest(archivePath: string, passphrase: string): Promise<void> {
await runQuiet(sevenZipTestCommand(archivePath, passphrase));
/** Throws if the archive is corrupt. */
export async function sevenZipTest(archivePath: string): Promise<void> {
await runQuiet(sevenZipTestCommand(archivePath));
}
/** For SMALL members only (manifest.json, .env) — capped at 1 MiB. */
export async function sevenZipReadMember(archivePath: string, passphrase: string, member: string): Promise<string> {
const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, passphrase, member), { maxBytes: 1024 * 1024 });
export async function sevenZipReadMember(archivePath: string, member: string): Promise<string> {
const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, member), { maxBytes: 1024 * 1024 });
return stdout;
}