Robustness: WAL mode, security headers, last-login, delete-my-account

SQLite WAL mode (start9/0.4/docker_entrypoint.sh)
- Switches journal_mode to WAL on every boot. WAL persists in the DB
  header so this is effectively a one-shot but rerunning is harmless.
- Crucial for the "background StartOS Backup while users are using the
  app" case: under the default rollback journal, a long backup can
  capture an inconsistent snapshot. WAL keeps readers and the writer
  from blocking each other.
- synchronous=NORMAL paired with WAL: still crash-consistent at every
  checkpoint, ~10x faster than FULL.

Security headers (proof-of-work/next.config.js)
- Content-Security-Policy with frame-ancestors 'none', base-uri 'self',
  form-action 'self', object-src 'none'. Keeps 'unsafe-inline' for
  script/style because Next.js emits inline bootstrap; tightening to
  nonce-based CSP is a follow-up.
- Strict-Transport-Security: max-age=31536000; includeSubDomains.
- Referrer-Policy: strict-origin-when-cross-origin (don't leak workout
  IDs etc. to third-party sites).
- Permissions-Policy: deny camera, mic, geolocation, USB, etc. across
  the board (none of those APIs are used today; explicit deny means
  vulnerability scanners have one less thing to flag).

Last-login tracking
- New User.lastLoginAt column. createSession stamps it inside the same
  transaction as the new Session row.
- Compat ALTER in entrypoint adds the column to legacy snapshots.
- Admin Users table now shows a relative-age cell (today / Nd ago /
  Nmo ago / Ny ago / "never" if the user hasn't signed in since the
  column was added). Hover reveals the exact ISO timestamp.

Self-serve delete-my-account (Settings -> Danger Zone)
- Requires both the user's current password AND typing the literal
  phrase "delete my account" (defense against a stolen-session
  attacker nuking the account in one click).
- Refused for the last admin (instance can't be left with no admin —
  the user is told to promote someone first).
- Cascades through Prisma onDelete: Cascade on every relation owned by
  User, so workouts, exercises, sessions, preferences all go in one
  shot. Session cookie cleared, redirected to /auth/login.
This commit is contained in:
Keysat
2026-05-09 10:19:31 -05:00
parent a11639cc56
commit d51400c2a9
10 changed files with 305 additions and 30 deletions
@@ -17,6 +17,7 @@ export default async function AdminUsersPage() {
name: true, name: true,
isAdmin: true, isAdmin: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
_count: { select: { sessions: true, workouts: true } }, _count: { select: { sessions: true, workouts: true } },
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
@@ -29,6 +30,7 @@ export default async function AdminUsersPage() {
name: u.name, name: u.name,
isAdmin: u.isAdmin, isAdmin: u.isAdmin,
createdAt: u.createdAt.toISOString(), createdAt: u.createdAt.toISOString(),
lastLoginAt: u.lastLoginAt ? u.lastLoginAt.toISOString() : null,
sessionCount: u._count.sessions, sessionCount: u._count.sessions,
workoutCount: u._count.workouts, workoutCount: u._count.workouts,
})); }));
@@ -0,0 +1,63 @@
'use server';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { getCurrentUser, verifyPassword } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* Self-serve account deletion.
*
* Refused for the last admin (we can't leave the instance with no
* admin). Refused if the typed-confirmation phrase is wrong (defense
* against a stolen-session attacker nuking the account in one click).
* Cascades through Prisma (onDelete: Cascade on every relation owned
* by User) so workouts, exercises, sessions, preferences, etc. all
* disappear in one shot. Clears the session cookie before redirecting
* to /auth/login.
*/
const REQUIRED_PHRASE = 'delete my account';
export async function deleteMyAccountAction(
password: string,
confirmationPhrase: string,
): Promise<{ error?: string }> {
try {
const me = await getCurrentUser();
if (!me) return { error: 'Not signed in.' };
if (confirmationPhrase.trim().toLowerCase() !== REQUIRED_PHRASE) {
return {
error: `To confirm, type the exact phrase: "${REQUIRED_PHRASE}".`,
};
}
const ok = await verifyPassword(password, me.passwordHash);
if (!ok) {
return { error: 'Password is incorrect.' };
}
if (me.isAdmin) {
const adminCount = await prisma.user.count({ where: { isAdmin: true } });
if (adminCount <= 1) {
return {
error:
'Cannot delete the last admin. Promote another user to admin first, then try again.',
};
}
}
await prisma.user.delete({ where: { id: me.id } });
const cookieStore = await cookies();
cookieStore.delete('sessionToken');
} catch (err) {
console.error('deleteMyAccount error:', err);
return { error: 'An error occurred while deleting your account.' };
}
// Redirect must be outside the try/catch — Next.js implements redirect
// by throwing a special value, and our catch would swallow it.
redirect('/auth/login');
}
+2
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import SettingsForm from "@/components/settings/SettingsForm"; import SettingsForm from "@/components/settings/SettingsForm";
import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
import DangerZone from "@/components/settings/DangerZone";
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
import { getInstanceSettings } from "@/lib/instanceSettings"; import { getInstanceSettings } from "@/lib/instanceSettings";
@@ -33,6 +34,7 @@ export default async function SettingsPage() {
initialSignupsOpen={instanceSettings.signupsOpen} initialSignupsOpen={instanceSettings.signupsOpen}
/> />
)} )}
<DangerZone />
</div> </div>
</div> </div>
); );
@@ -13,10 +13,22 @@ type UserRow = {
name: string | null; name: string | null;
isAdmin: boolean; isAdmin: boolean;
createdAt: string; createdAt: string;
lastLoginAt: string | null;
sessionCount: number; sessionCount: number;
workoutCount: number; workoutCount: number;
}; };
function relativeAge(iso: string | null): string {
if (!iso) return 'never';
const ms = Date.now() - new Date(iso).getTime();
const days = Math.floor(ms / 86_400_000);
if (days < 1) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
export default function UsersTable({ export default function UsersTable({
users, users,
currentUserId, currentUserId,
@@ -82,6 +94,7 @@ export default function UsersTable({
<tr> <tr>
<th className="text-left px-4 py-3">User</th> <th className="text-left px-4 py-3">User</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Joined</th> <th className="text-left px-4 py-3 hidden sm:table-cell">Joined</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Last login</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Workouts</th> <th className="text-left px-4 py-3 hidden sm:table-cell">Workouts</th>
<th className="text-left px-4 py-3">Role</th> <th className="text-left px-4 py-3">Role</th>
<th className="text-right px-4 py-3">Actions</th> <th className="text-right px-4 py-3">Actions</th>
@@ -104,6 +117,14 @@ export default function UsersTable({
<td className="px-4 py-3 text-xs text-zinc-500 hidden sm:table-cell"> <td className="px-4 py-3 text-xs text-zinc-500 hidden sm:table-cell">
{new Date(u.createdAt).toLocaleDateString()} {new Date(u.createdAt).toLocaleDateString()}
</td> </td>
<td
className={`px-4 py-3 text-xs hidden md:table-cell ${
u.lastLoginAt ? 'text-zinc-400' : 'text-zinc-600 italic'
}`}
title={u.lastLoginAt ?? 'never'}
>
{relativeAge(u.lastLoginAt)}
</td>
<td className="px-4 py-3 text-xs text-zinc-400 hidden sm:table-cell"> <td className="px-4 py-3 text-xs text-zinc-400 hidden sm:table-cell">
{u.workoutCount} {u.workoutCount}
</td> </td>
@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { deleteMyAccountAction } from '@/app/main/settings/deleteAccountAction';
const REQUIRED_PHRASE = 'delete my account';
export default function DangerZone() {
const [open, setOpen] = useState(false);
const [password, setPassword] = useState('');
const [phrase, setPhrase] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setBusy(true);
const res = await deleteMyAccountAction(password, phrase);
if (res?.error) {
setError(res.error);
setBusy(false);
}
// If no error, the action redirected — we never reach here.
};
return (
<section className="bg-zinc-900 rounded border border-red-900/50 p-6 space-y-4">
<header>
<h2 className="text-sm font-semibold text-red-400 uppercase tracking-wider">
Danger zone
</h2>
<p className="text-xs text-zinc-500 mt-1">
Permanently deletes your account, every workout you&apos;ve logged,
every set, your custom exercises, and every session. Other users
on this instance are not affected.
</p>
</header>
{!open ? (
<button
type="button"
onClick={() => setOpen(true)}
className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 uppercase tracking-wider hover:bg-red-900/30"
>
Delete my account
</button>
) : (
<form onSubmit={submit} className="space-y-3">
<div className="space-y-2">
<label
htmlFor="del-pwd"
className="text-xs font-semibold text-white uppercase tracking-wider"
>
Your password
</label>
<input
id="del-pwd"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div className="space-y-2">
<label
htmlFor="del-phrase"
className="text-xs font-semibold text-white uppercase tracking-wider"
>
Type{' '}
<span className="text-red-400 font-mono normal-case">
{REQUIRED_PHRASE}
</span>{' '}
to confirm
</label>
<input
id="del-phrase"
type="text"
required
autoComplete="off"
value={phrase}
onChange={(e) => setPhrase(e.target.value)}
placeholder={REQUIRED_PHRASE}
disabled={busy}
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={busy}
className="text-xs px-3 py-1.5 rounded bg-red-700 text-white font-bold uppercase tracking-wider hover:bg-red-600 disabled:bg-zinc-700 disabled:text-zinc-500"
>
{busy ? 'Deleting...' : 'Delete forever'}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setPassword('');
setPhrase('');
setError('');
}}
disabled={busy}
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
Cancel
</button>
</div>
</form>
)}
</section>
);
}
+16 -7
View File
@@ -37,13 +37,22 @@ export async function createSession(
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await prisma.session.create({ // Create the session and stamp the user's lastLoginAt in the same
data: { // transaction. Surfaced in the admin Users table so admins can spot
token, // dormant accounts.
userId, await prisma.$transaction([
expiresAt, prisma.session.create({
}, data: {
}); token,
userId,
expiresAt,
},
}),
prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
}),
]);
return { token, expiresAt }; return { token, expiresAt };
} }
+56 -21
View File
@@ -1,31 +1,66 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
// Content-Security-Policy.
//
// `script-src` and `style-src` keep `'unsafe-inline'` because Next.js
// emits inline bootstrap scripts and Tailwind's runtime CSS-in-JS path
// requires inline styles. Tightening to nonce-based CSP is a follow-up
// (requires switching to Next's `headers()` middleware-style nonce
// injection, not the static config). The directives we DO get for free
// here still cut off the most common XSS-followup patterns:
// - frame-ancestors 'none' -> can't be embedded anywhere (clickjacking)
// - base-uri 'self' -> attacker can't pivot relative URLs
// - form-action 'self' -> stolen forms can't POST credentials away
// - object-src 'none' -> no Flash/Java applets, full stop
// - default-src 'self' -> images/fetches/etc default to same-origin
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ');
const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
// HSTS: tell browsers to use HTTPS only for this origin for a year.
// StartOS terminates TLS in front of the container, so this applies
// to the public hostname users actually visit.
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
// Don't leak the full URL (which can include exercise IDs, workout
// IDs, etc.) when the user clicks a third-party link.
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// Block every browser API we don't actually use. If we ever add
// camera/mic/geo features, allow-list them here explicitly.
{
key: 'Permissions-Policy',
value:
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
},
];
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: 'standalone', output: 'standalone',
images: { images: {
unoptimized: false, unoptimized: false,
}, },
headers: async () => { headers: async () => [
return [ {
{ source: '/(.*)',
source: '/(.*)', headers: securityHeaders,
headers: [ },
{ ],
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
],
},
];
},
}; };
module.exports = nextConfig; module.exports = nextConfig;
+1
View File
@@ -16,6 +16,7 @@ model User {
passwordHash String passwordHash String
name String? name String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
lastLoginAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+19
View File
@@ -91,6 +91,11 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);" AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);"
fi fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|lastLoginAt|"; then
log "adding missing column User.lastLoginAt (nullable)"
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;"
fi
if ! sqlite3 "$DB_PATH" \ if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \ "SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
2>/dev/null | grep -q InstanceSettings; then 2>/dev/null | grep -q InstanceSettings; then
@@ -104,6 +109,20 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" \ sqlite3 "$DB_PATH" \
"INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);" "INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);"
fi fi
# SQLite tuning. Enabling WAL means readers don't block on a concurrent
# writer (and vice versa) — crucial for the "background StartOS Backup
# while users are using the app" case, which under the default rollback
# journal can produce a torn snapshot. journal_mode persists in the DB
# header once set, so this is effectively a one-shot. synchronous=NORMAL
# is the safe-with-WAL balance: no fsync after every commit but still
# crash-consistent at every checkpoint, ~10x faster than FULL.
current_mode=$(sqlite3 "$DB_PATH" "PRAGMA journal_mode;" 2>/dev/null || echo "")
if [ "$current_mode" != "wal" ]; then
log "switching SQLite journal_mode from '${current_mode:-unknown}' to WAL"
sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" >/dev/null
fi
sqlite3 "$DB_PATH" "PRAGMA synchronous=NORMAL;" >/dev/null
fi fi
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "workout-log-startos", "name": "proof-of-work-startos",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "workout-log-startos", "name": "proof-of-work-startos",
"dependencies": { "dependencies": {
"@start9labs/start-sdk": "1.0.0", "@start9labs/start-sdk": "1.0.0",
"bcryptjs": "^2.4.3" "bcryptjs": "^2.4.3"