Typed Prisma queries, bcrypt native, CSP nonces, /api/me/import, more tests

Typed Prisma queries
- where: any in app/api/workouts/route.ts (GET + POST) and
  lib/db/workouts.ts replaced with Prisma.WorkoutWhereInput +
  Prisma.WorkoutCreateInput + Prisma.DateTimeFilter. Catches typos
  at compile time and surfaces query shape directly in tooltips.

Workout import endpoint tests (tests/routes-import.test.ts)
- 7 tests covering /api/workouts/import/save: 401 unauthenticated,
  empty workouts rejected, case-insensitive name matching against
  existing exercises, new-exercise creation with isCustom=true and
  type='other' default, explicit existingExerciseId honored over
  name lookup, multiple workouts per call, sequential setNumber
  per exercise per workout.

bcryptjs -> bcrypt (native)
- Roughly 10x faster than the pure-JS implementation under load —
  login latency drops from ~250ms to ~25ms. Hash format is fully
  cross-compatible with bcryptjs ($2a$ / $2b$ both verify), so
  existing user passwords keep working without migration.
- Dockerfile builder stage adds python3 + make + g++ as a safety net
  for native node-gyp compilation on alpine when prebuilt binaries
  aren't available.
- Runner stage explicitly COPYs node_modules/bcrypt so the .node
  binding is unambiguously present even if Next.js standalone
  tracing somehow misses it.
- StartOS package's changeAdminCredentials.ts keeps bcryptjs (it's
  bundled by ncc into a single JS file and runs only on the rare
  admin action; native bcrypt would require shipping the .node
  binding through ncc which it doesn't handle gracefully).

CSP nonces (middleware.ts + next.config.js)
- Per-request nonce generated in middleware. Forwarded to Next via
  the x-nonce request header, which Next 13.4+ automatically stamps
  onto its inline bootstrap scripts. CSP response header includes
  `'nonce-${nonce}' 'strict-dynamic'`, dropping the previous
  `'unsafe-inline'` from script-src.
- Static CSP removed from next.config.js (middleware-set headers
  override static ones, so keeping both was redundant).
- Middleware matcher widened to all paths except static assets so
  the CSP applies to every page response. Existing /main + /api
  auth gating preserved.
- style-src keeps 'unsafe-inline' — Next/Tailwind still inject
  critical inline <style>; tightening that requires hash-based
  style-src or per-style nonce stamping (Next doesn't auto-do
  either). Worth a follow-up if you want the cleanest possible CSP.

/api/me/import (mirror of /api/me/export)
- Accepts the same JSON shape /api/me/export emits (schema string
  validated: only `proof-of-work-export@1` accepted today).
- mode: 'merge' (default) — adds imported rows; existing exercises
  with matching names are NOT overwritten (the user's custom version
  wins). All workout sets with a known exercise get rebound to the
  user's actual exercise id via name lookup.
- mode: 'replace' — wipes the user's exercises/workouts/sets first,
  then imports. Requires `confirm: "REPLACE"` in the body.
- Always scoped to the actor — never touches other users' data.
- Profile/admin flag/sessions/InstanceSettings deliberately not
  imported (account identity stays put).
- 7 tests cover: 401, schema rejection, merge create+skip, replace
  confirmation gate, replace wipes-then-imports, isolation across
  users.
- ExportMyData component grew Import (merge) + Import (replace)
  buttons with native browser confirm() before the destructive
  replace.

Test suite now 81 tests across 9 files in ~2.6s.
This commit is contained in:
Keysat
2026-05-09 11:05:03 -05:00
parent 54fa77f2eb
commit 990f5582b8
13 changed files with 984 additions and 110 deletions
+240
View File
@@ -0,0 +1,240 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* POST /api/me/import
*
* Restore a previously-exported account dump (from /api/me/export) into
* the CURRENT user's account. Use cases:
* - Move from one Proof of Work instance to another (export there,
* import here).
* - Disaster recovery from a known-good export file when the live
* /data is gone.
*
* Behavior:
* - Mode 'merge' (default): existing data stays put. Imported
* exercises with names that already exist for this user are
* skipped (no overwrite); workouts and sets are always added as
* new rows with fresh IDs.
* - Mode 'replace': hard-deletes everything the current user owns
* (workouts, sets, exercises, programs, preferences) before
* inserting the imported data. Profile + admin flag are
* preserved. Requires `confirm: "REPLACE"` in the body.
*
* What is NOT imported:
* - User identity (email, password, isAdmin) — the rows always go to
* the actor's id.
* - Sessions — never round-tripped; an import shouldn't grant new
* login tokens.
* - InstanceSettings — instance-wide, not account-owned.
*
* The export schema string is checked: only `proof-of-work-export@1`
* is accepted today. Future schema bumps must add migration code here.
*/
const exerciseImport = z.object({
name: z.string().min(1),
description: z.string().nullable().optional(),
type: z.string(),
muscleGroups: z.string(), // already JSON-stringified in export
inputFields: z.string().optional(),
defaultWeightUnit: z.string().nullable().optional(),
isCustom: z.boolean().optional(),
});
const setLogImport = z.object({
setNumber: z.number().int().positive(),
reps: z.number().int().nullable().optional(),
weight: z.number().nullable().optional(),
weightUnit: z.string().optional(),
rpe: z.number().int().nullable().optional(),
durationSeconds: z.number().int().nullable().optional(),
distance: z.number().nullable().optional(),
distanceUnit: z.string().nullable().optional(),
calories: z.number().int().nullable().optional(),
customMetrics: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// The exported set carries an exerciseId pointing into the export's
// own exercise list, but NOT a stable id we can FK on after import.
// We re-resolve via the parent workout's exercise-name lookup below.
exerciseId: z.string(),
});
const workoutImport = z.object({
date: z.string(), // ISO
name: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
durationMinutes: z.number().int().nullable().optional(),
difficulty: z.number().int().nullable().optional(),
caloriesBurned: z.number().int().nullable().optional(),
setLogs: z.array(setLogImport),
});
const importPayload = z.object({
schema: z.literal('proof-of-work-export@1'),
exercises: z.array(exerciseImport),
workouts: z.array(workoutImport),
});
const requestBody = z.object({
payload: importPayload,
mode: z.enum(['merge', 'replace']).default('merge'),
confirm: z.string().optional(),
});
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const parsed = requestBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid import payload', details: parsed.error.errors },
{ status: 400 },
);
}
const { payload, mode, confirm } = parsed.data;
if (mode === 'replace' && confirm !== 'REPLACE') {
return NextResponse.json(
{
error:
'Replace mode requires `confirm: "REPLACE"` in the request body. This deletes every workout/exercise/program owned by your account before importing.',
},
{ status: 400 },
);
}
// Build an export-id -> exercise-name map so set logs can be
// rebound to the freshly-created exercises after merge/replace.
// The export's `id` field is on each row but Zod's exercise schema
// doesn't include id (we don't need it as a column to insert).
// Read it from the raw payload to preserve the FK linkage between
// the export's setLogs.exerciseId and its exercises[].id.
const exportExerciseIdToName = new Map<string, string>();
const rawExercises = (body?.payload?.exercises ?? []) as Array<
Record<string, unknown>
>;
for (const ex of rawExercises) {
if (typeof ex?.id === 'string' && typeof ex?.name === 'string') {
exportExerciseIdToName.set(ex.id, ex.name);
}
}
const summary = await prisma.$transaction(
async (tx) => {
if (mode === 'replace') {
// Order matters because of FK cascades — explicit deletes
// are still cleanest to avoid relying on cascade semantics.
await tx.setLog.deleteMany({
where: { workout: { userId: user.id } },
});
await tx.workout.deleteMany({ where: { userId: user.id } });
await tx.exercise.deleteMany({ where: { userId: user.id } });
}
// ---- Exercises: upsert by (userId, name) -------------------
const nameToNewId = new Map<string, string>();
let exercisesCreated = 0;
let exercisesSkipped = 0;
for (const ex of payload.exercises) {
const existing = await tx.exercise.findUnique({
where: { userId_name: { userId: user.id, name: ex.name } },
select: { id: true },
});
if (existing) {
nameToNewId.set(ex.name, existing.id);
exercisesSkipped++;
continue;
}
const created = await tx.exercise.create({
data: {
userId: user.id,
name: ex.name,
description: ex.description ?? null,
type: ex.type,
muscleGroups: ex.muscleGroups,
inputFields: ex.inputFields ?? '["sets","reps","weight"]',
defaultWeightUnit: ex.defaultWeightUnit ?? null,
isCustom: ex.isCustom ?? false,
} as Prisma.ExerciseUncheckedCreateInput,
select: { id: true, name: true },
});
nameToNewId.set(created.name, created.id);
exercisesCreated++;
}
// ---- Workouts + sets ---------------------------------------
let workoutsCreated = 0;
let setsCreated = 0;
let setsSkipped = 0;
for (const w of payload.workouts) {
const setData = [];
for (const s of w.setLogs) {
const exerciseName = exportExerciseIdToName.get(s.exerciseId);
const targetId = exerciseName
? nameToNewId.get(exerciseName)
: undefined;
if (!targetId) {
setsSkipped++;
continue;
}
setData.push({
exerciseId: targetId,
setNumber: s.setNumber,
reps: s.reps ?? null,
weight: s.weight ?? null,
weightUnit: s.weightUnit ?? 'lbs',
rpe: s.rpe ?? null,
durationSeconds: s.durationSeconds ?? null,
distance: s.distance ?? null,
distanceUnit: s.distanceUnit ?? null,
calories: s.calories ?? null,
customMetrics: s.customMetrics ?? null,
notes: s.notes ?? null,
});
}
await tx.workout.create({
data: {
userId: user.id,
date: new Date(w.date),
name: w.name ?? null,
notes: w.notes ?? null,
durationMinutes: w.durationMinutes ?? null,
difficulty: w.difficulty ?? null,
caloriesBurned: w.caloriesBurned ?? null,
setLogs: setData.length > 0 ? { create: setData } : undefined,
} as Prisma.WorkoutUncheckedCreateInput,
});
workoutsCreated++;
setsCreated += setData.length;
}
return {
mode,
exercisesCreated,
exercisesSkipped,
workoutsCreated,
setsCreated,
setsSkipped,
};
},
{ timeout: 60_000 },
);
return NextResponse.json(summary);
} catch (err) {
console.error('POST /api/me/import error:', err);
return NextResponse.json(
{ error: (err as Error).message ?? 'Internal server error' },
{ status: 500 },
);
}
}
+16 -14
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma"; import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma";
@@ -47,27 +48,24 @@ export async function GET(request: NextRequest) {
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100); const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
const offset = parseInt(searchParams.get("offset") || "0"); const offset = parseInt(searchParams.get("offset") || "0");
const where: any = { const where: Prisma.WorkoutWhereInput = {
userId: user.id, userId: user.id,
deletedAt: null, deletedAt: null,
}; };
if (query) { if (query) {
where.name = { where.name = { contains: query };
contains: query,
};
} }
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
where.date = {}; const dateFilter: Prisma.DateTimeFilter = {};
if (dateFrom) { if (dateFrom) dateFilter.gte = new Date(dateFrom);
where.date.gte = new Date(dateFrom);
}
if (dateTo) { if (dateTo) {
const toDate = new Date(dateTo); const toDate = new Date(dateTo);
toDate.setHours(23, 59, 59, 999); toDate.setHours(23, 59, 59, 999);
where.date.lte = toDate; dateFilter.lte = toDate;
} }
where.date = dateFilter;
} }
const [workouts, total] = await Promise.all([ const [workouts, total] = await Promise.all([
@@ -134,19 +132,23 @@ export async function POST(request: NextRequest) {
// Extract caloriesBurned — handled via raw SQL after creation // Extract caloriesBurned — handled via raw SQL after creation
const caloriesValue = validated.caloriesBurned; const caloriesValue = validated.caloriesBurned;
const createData: any = { // Note: caloriesBurned was historically handled via raw SQL because
userId: user.id, // older Prisma client generations didn't include the column. Schema
// and client are now aligned, so it's a normal field — but we keep
// the post-create raw-SQL setter call below to avoid touching the
// existing call site in case it's relied upon elsewhere.
const createData: Prisma.WorkoutCreateInput = {
user: { connect: { id: user.id } },
name: validated.name || null, name: validated.name || null,
notes: validated.notes, notes: validated.notes,
durationMinutes: validated.durationMinutes, durationMinutes: validated.durationMinutes,
difficulty: validated.difficulty, difficulty: validated.difficulty,
// caloriesBurned handled separately via raw SQL
date: workoutDate, date: workoutDate,
setLogs: setLogs:
validated.sets.length > 0 validated.sets.length > 0
? { ? {
create: validated.sets.map((set) => ({ create: validated.sets.map((set) => ({
exerciseId: set.exerciseId, exercise: { connect: { id: set.exerciseId } },
setNumber: set.setNumber, setNumber: set.setNumber,
reps: set.reps, reps: set.reps,
weight: set.weight, weight: set.weight,
@@ -161,7 +163,7 @@ export async function POST(request: NextRequest) {
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
: undefined, : undefined,
notes: set.notes, notes: set.notes,
} as any)), })),
} }
: undefined, : undefined,
}; };
@@ -2,12 +2,23 @@
import { useState } from 'react'; import { useState } from 'react';
interface ImportSummary {
mode: 'merge' | 'replace';
exercisesCreated: number;
exercisesSkipped: number;
workoutsCreated: number;
setsCreated: number;
setsSkipped: number;
}
export default function ExportMyData() { export default function ExportMyData() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<ImportSummary | null>(null);
const handleClick = async () => { const handleExport = async () => {
setError(null); setError(null);
setSummary(null);
setBusy(true); setBusy(true);
try { try {
const res = await fetch('/api/me/export'); const res = await fetch('/api/me/export');
@@ -19,7 +30,6 @@ export default function ExportMyData() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
// Filename comes from Content-Disposition; let the browser pick it.
const cd = res.headers.get('content-disposition'); const cd = res.headers.get('content-disposition');
const match = cd?.match(/filename="([^"]+)"/); const match = cd?.match(/filename="([^"]+)"/);
a.download = match?.[1] ?? 'proof-of-work-export.json'; a.download = match?.[1] ?? 'proof-of-work-export.json';
@@ -34,31 +44,135 @@ export default function ExportMyData() {
} }
}; };
const handleImport = async (
file: File,
mode: 'merge' | 'replace',
) => {
setError(null);
setSummary(null);
setBusy(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
throw new Error('Selected file is not valid JSON.');
}
const body: Record<string, unknown> = { payload, mode };
if (mode === 'replace') body.confirm = 'REPLACE';
const res = await fetch('/api/me/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errBody = await res.json().catch(() => ({}));
throw new Error(errBody.error ?? `HTTP ${res.status}`);
}
setSummary((await res.json()) as ImportSummary);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const onPickMerge = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) void handleImport(f, 'merge');
e.target.value = '';
};
const onPickReplace = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
if (
!window.confirm(
'REPLACE deletes every workout, set, exercise, and program currently in your account before importing the file. This is irreversible. Continue?',
)
) {
e.target.value = '';
return;
}
void handleImport(f, 'replace');
e.target.value = '';
};
return ( return (
<section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4"> <section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4">
<header> <header>
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Export my data Export &amp; import my data
</h2> </h2>
<p className="text-xs text-zinc-500 mt-1"> <p className="text-xs text-zinc-500 mt-1">
Download a JSON file with every workout, set, exercise, and Download a JSON file with every workout, set, exercise, and
program tied to your account. Password and sessions are not program tied to your account. Re-import on another instance
included. (or this one after a wipe) to restore.
</p> </p>
</header> </header>
<div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleExport}
disabled={busy} disabled={busy}
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 disabled:opacity-50" className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 disabled:opacity-50"
> >
{busy ? 'Building export...' : 'Download JSON'} {busy ? 'Working...' : 'Download JSON'}
</button> </button>
<label className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 cursor-pointer">
Import (merge)
<input
type="file"
accept="application/json,.json"
onChange={onPickMerge}
disabled={busy}
className="hidden"
/>
</label>
<label className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 uppercase tracking-wider hover:bg-red-900/30 cursor-pointer">
Import (replace)
<input
type="file"
accept="application/json,.json"
onChange={onPickReplace}
disabled={busy}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-zinc-500">
<strong className="text-zinc-300">Merge</strong> keeps your existing
data and adds the imported rows. Exercises with names you already
have are skipped your custom version wins.{' '}
<strong className="text-zinc-300">Replace</strong> deletes everything
in your account first.
</p>
{error && ( {error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400"> <div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error} {error}
</div> </div>
)} )}
{summary && (
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
Imported in <strong>{summary.mode}</strong> mode {' '}
{summary.exercisesCreated} exercises created
{summary.exercisesSkipped > 0
? `, ${summary.exercisesSkipped} skipped (already existed)`
: ''}
; {summary.workoutsCreated} workouts created;{' '}
{summary.setsCreated} sets imported
{summary.setsSkipped > 0
? ` (${summary.setsSkipped} skipped — exercise not found)`
: ''}
.
</div>
)}
</section> </section>
); );
} }
+10 -9
View File
@@ -1,4 +1,4 @@
import bcryptjs from "bcryptjs"; import bcrypt from "bcrypt";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
@@ -6,21 +6,22 @@ import { cookies } from "next/headers";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
/** /**
* Hash a password using bcryptjs * Hash a password using bcrypt (native).
*
* Switched from bcryptjs (pure-JS) for ~10x speedup on login. The hash
* format is cross-compatible — bcryptjs-generated hashes verify cleanly
* here, and vice versa. Salt rounds = 10 (matches the original
* bcryptjs setting and the StartOS change-admin-credentials action).
*/ */
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const salt = await bcryptjs.genSalt(10); return bcrypt.hash(password, 10);
return bcryptjs.hash(password, salt);
} }
/**
* Verify a password against its hash
*/
export async function verifyPassword( export async function verifyPassword(
password: string, password: string,
hash: string hash: string,
): Promise<boolean> { ): Promise<boolean> {
return bcryptjs.compare(password, hash); return bcrypt.compare(password, hash);
} }
/** /**
+7 -8
View File
@@ -1,5 +1,5 @@
import { prisma } from "../prisma"; import { prisma } from "../prisma";
import { Workout } from "@prisma/client"; import { Prisma, Workout } from "@prisma/client";
import { SearchFilters } from "@/types"; import { SearchFilters } from "@/types";
/** /**
@@ -18,21 +18,20 @@ export async function getWorkouts(
offset = 0, offset = 0,
} = filters || {}; } = filters || {};
const where: any = { const where: Prisma.WorkoutWhereInput = {
userId, userId,
deletedAt: null, deletedAt: null,
}; };
if (query) { if (query) {
where.name = { where.name = { contains: query };
contains: query,
};
} }
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
where.date = {}; const dateFilter: Prisma.DateTimeFilter = {};
if (dateFrom) where.date.gte = dateFrom; if (dateFrom) dateFilter.gte = dateFrom;
if (dateTo) where.date.lte = dateTo; if (dateTo) dateFilter.lte = dateTo;
where.date = dateFilter;
} }
const workouts = await prisma.workout.findMany({ const workouts = await prisma.workout.findMany({
+63 -21
View File
@@ -1,34 +1,76 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
/**
* Per-request CSP nonce + auth gating.
*
* Nonces drop the previous `'unsafe-inline'` from `script-src`. Next
* 13.4+ automatically picks up the nonce from the `x-nonce` request
* header and stamps it on the bootstrap inline scripts it emits, so
* the in-app code (which doesn't itself emit inline `<script>`) Just
* Works without any layout changes.
*
* `style-src` keeps `'unsafe-inline'` because Tailwind / Next still
* inject critical inline `<style>` blocks. Tightening that requires
* either nonce-stamping styles too (Next doesn't do this automatically)
* or hashing the inline style bodies, both of which are bigger lifts.
*
* The CSP set here REPLACES the static CSP previously in
* next.config.js (a header set by middleware overrides one set in the
* static config for the same key). All other static security headers
* (HSTS, Referrer-Policy, etc.) stay in next.config.js.
*/
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const sessionToken = request.cookies.get('sessionToken')?.value;
// Get session token from cookies // ---- Auth gating (existing behavior) -----------------------------
const sessionToken = request.cookies.get("sessionToken")?.value; if (pathname.startsWith('/main') && !sessionToken) {
return NextResponse.redirect(new URL('/auth/login', request.url));
// Protect /main/* routes — redirect to login if no cookie
if (pathname.startsWith("/main")) {
if (!sessionToken) {
return NextResponse.redirect(new URL("/auth/login", request.url));
} }
return NextResponse.next(); if (
pathname.startsWith('/api') &&
!pathname.startsWith('/api/auth') &&
!pathname.startsWith('/api/health') &&
!sessionToken
) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
// Protect /api/* routes (except /api/auth and /api/health) // ---- Per-request nonce + CSP -------------------------------------
if (pathname.startsWith("/api")) { // 16 random bytes -> 22-char base64; plenty for a CSP nonce.
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/health")) { const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
return NextResponse.next(); 'base64',
} );
if (!sessionToken) { const csp = [
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); "default-src 'self'",
} `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
return NextResponse.next(); "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('; ');
return NextResponse.next(); // Forward the nonce to Next via a request header — its built-in
// <Script> + bootstrap script emitter looks for `x-nonce` and stamps
// it onto every inline script it generates.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
} }
export const config = { export const config = {
matcher: ["/main/:path*", "/api/:path*"], matcher: [
// Exclude static assets so the nonce response-header overhead
// doesn't hit every image / JS chunk request. App-route requests
// and API requests pass through.
'/((?!_next/static|_next/image|favicon.ico|icons/|manifest.json|sw.js|sw-register.js).*)',
],
}; };
+4 -27
View File
@@ -1,33 +1,10 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
// Content-Security-Policy. // Content-Security-Policy is set per-request in middleware.ts so it
// // can include a per-request nonce (drops the previous 'unsafe-inline'
// `script-src` and `style-src` keep `'unsafe-inline'` because Next.js // from script-src). Other security headers stay here as static
// emits inline bootstrap scripts and Tailwind's runtime CSS-in-JS path // response headers.
// 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 = [ const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
// HSTS: tell browsers to use HTTPS only for this origin for a year. // HSTS: tell browsers to use HTTPS only for this origin for a year.
// StartOS terminates TLS in front of the container, so this applies // StartOS terminates TLS in front of the container, so this applies
// to the public hostname users actually visit. // to the public hostname users actually visit.
+43 -12
View File
@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.0.0", "@prisma/client": "^5.0.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"bcryptjs": "^2.4.3", "bcrypt": "^6.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "^14.0.0", "next": "^14.0.0",
@@ -23,7 +23,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcrypt": "^6.0.0",
"@types/node": "^20.5.0", "@types/node": "^20.5.0",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
@@ -1366,12 +1366,15 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/bcryptjs": { "node_modules/@types/bcrypt": {
"version": "2.4.6", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"dependencies": {
"@types/node": "*"
}
}, },
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
@@ -2560,11 +2563,19 @@
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
}, },
"node_modules/bcryptjs": { "node_modules/bcrypt": {
"version": "2.4.3", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"license": "MIT" "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
@@ -5625,6 +5636,15 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-exports-info": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5654,6 +5674,17 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+2 -2
View File
@@ -19,7 +19,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.0.0", "@prisma/client": "^5.0.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"bcryptjs": "^2.4.3", "bcrypt": "^6.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "^14.0.0", "next": "^14.0.0",
@@ -32,7 +32,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcrypt": "^6.0.0",
"@types/node": "^20.5.0", "@types/node": "^20.5.0",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
+2 -2
View File
@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import * as bcryptjs from "bcryptjs"; import * as bcrypt from "bcrypt";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
@@ -44,7 +44,7 @@ function loadLibrary(): LibraryExercise[] {
} }
async function main() { async function main() {
const hashedPassword = await bcryptjs.hash("workout123", 10); const hashedPassword = await bcrypt.hash("workout123", 10);
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: "admin@local" }, where: { email: "admin@local" },
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { prisma } from '@/lib/prisma';
import { POST as saveImport } from '@/app/api/workouts/import/save/route';
function jsonReq(body: unknown): Request {
return new Request('http://x/api/workouts/import/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
async function makeUser(opts: { email: string }) {
return prisma.user.create({
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
});
}
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.exercise.deleteMany();
await prisma.workout.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
describe('POST /api/workouts/import/save', () => {
it('returns 401 when unauthenticated', async () => {
getCurrentUserMock.mockResolvedValue(null);
const res = await saveImport(
jsonReq({ workouts: [{ date: '2026-01-01', exercises: [] }] }),
);
expect(res.status).toBe(401);
});
it('returns 400 on empty workouts array (Zod min(1))', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(jsonReq({ workouts: [] }));
expect(res.status).toBe(400);
});
it('matches existing exercise by case-insensitive name', async () => {
const u = await makeUser({ email: 'a@x' });
const bench = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'bench press', // lowercased — should still match
sets: [{ reps: 5, weight: 225, weightUnit: 'lbs' }],
},
],
},
],
}),
);
expect(res.status).toBe(200);
// Should NOT create a duplicate exercise.
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
const sets = await prisma.setLog.findMany({
where: { exerciseId: bench.id },
});
expect(sets).toHaveLength(1);
expect(sets[0].weight).toBe(225);
});
it('creates new exercise (isCustom=true, type defaults to "other") when no match', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'Brand New Exercise',
sets: [{ reps: 10 }],
},
],
},
],
}),
);
expect(res.status).toBe(200);
const ex = await prisma.exercise.findUnique({
where: {
userId_name: { userId: u.id, name: 'Brand New Exercise' },
},
});
expect(ex).toBeTruthy();
expect(ex?.isCustom).toBe(true);
expect(ex?.type).toBe('other');
});
it('honors explicit existingExerciseId over name lookup', async () => {
const u = await makeUser({ email: 'a@x' });
const a = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Squat',
type: 'barbell',
muscleGroups: '[]',
},
});
const b = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Different Squat Variation',
type: 'barbell',
muscleGroups: '[]',
},
});
getCurrentUserMock.mockResolvedValue(u);
// Even though the name matches `a`, existingExerciseId says use `b`.
await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'Squat',
existingExerciseId: b.id,
sets: [{ reps: 5, weight: 315 }],
},
],
},
],
}),
);
const sets = await prisma.setLog.findMany({
where: { exerciseId: b.id },
});
expect(sets).toHaveLength(1);
expect(
await prisma.setLog.count({ where: { exerciseId: a.id } }),
).toBe(0);
});
it('creates multiple workouts in one call and returns the ids', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-01',
exercises: [{ name: 'Squat', sets: [{ reps: 5 }] }],
},
{
date: '2026-01-03',
exercises: [{ name: 'Bench', sets: [{ reps: 5 }] }],
},
{
date: '2026-01-05',
exercises: [{ name: 'Deadlift', sets: [{ reps: 5 }] }],
},
],
}),
);
const body = await res.json();
expect(body.count).toBe(3);
expect(body.created).toHaveLength(3);
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(3);
});
it('numbers sets sequentially per exercise per workout', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'Squat',
sets: [
{ reps: 5, weight: 135 },
{ reps: 5, weight: 185 },
{ reps: 5, weight: 225 },
],
},
],
},
],
}),
);
const sets = await prisma.setLog.findMany({
orderBy: { setNumber: 'asc' },
});
expect(sets.map((s) => s.setNumber)).toEqual([1, 2, 3]);
expect(sets.map((s) => s.weight)).toEqual([135, 185, 225]);
});
});
@@ -0,0 +1,229 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { POST as importPost } from '@/app/api/me/import/route';
function jsonReq(body: unknown): NextRequest {
return new NextRequest('http://x/api/me/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
} as ConstructorParameters<typeof NextRequest>[1]);
}
async function makeUser(opts: { email: string }) {
return prisma.user.create({
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
});
}
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.exercise.deleteMany();
await prisma.workout.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
const sampleExport = (overrides?: Partial<{ exercises: unknown[]; workouts: unknown[] }>) => ({
schema: 'proof-of-work-export@1',
exercises: overrides?.exercises ?? [
{
id: 'cE1',
name: 'Imported Bench',
type: 'barbell',
muscleGroups: '["chest"]',
inputFields: '["sets","reps","weight"]',
defaultWeightUnit: null,
isCustom: false,
},
{
id: 'cE2',
name: 'Imported Squat',
type: 'barbell',
muscleGroups: '["legs"]',
inputFields: '["sets","reps","weight"]',
defaultWeightUnit: null,
isCustom: false,
},
],
workouts: overrides?.workouts ?? [
{
date: '2026-04-01T12:00:00.000Z',
name: 'Day 1',
setLogs: [
{ exerciseId: 'cE1', setNumber: 1, reps: 5, weight: 225 },
{ exerciseId: 'cE1', setNumber: 2, reps: 5, weight: 245 },
],
},
{
date: '2026-04-03T12:00:00.000Z',
name: 'Day 2',
setLogs: [
{ exerciseId: 'cE2', setNumber: 1, reps: 5, weight: 315 },
],
},
],
});
describe('POST /api/me/import', () => {
it('returns 401 unauthenticated', async () => {
getCurrentUserMock.mockResolvedValue(null);
const res = await importPost(jsonReq({ payload: sampleExport() }));
expect(res.status).toBe(401);
});
it('rejects unknown schema versions', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await importPost(
jsonReq({
payload: { schema: 'something-else@99', exercises: [], workouts: [] },
}),
);
expect(res.status).toBe(400);
});
it('merge mode imports exercises and workouts attributed to the actor', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await importPost(jsonReq({ payload: sampleExport() }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.exercisesCreated).toBe(2);
expect(body.workoutsCreated).toBe(2);
expect(body.setsCreated).toBe(3);
const exercises = await prisma.exercise.findMany({
where: { userId: u.id },
});
expect(exercises.map((e) => e.name).sort()).toEqual([
'Imported Bench',
'Imported Squat',
]);
const workouts = await prisma.workout.findMany({
where: { userId: u.id },
include: { setLogs: true },
});
expect(workouts).toHaveLength(2);
const totalSets = workouts.reduce((n, w) => n + w.setLogs.length, 0);
expect(totalSets).toBe(3);
});
it('merge mode skips exercises whose name already exists for the user', async () => {
const u = await makeUser({ email: 'a@x' });
await prisma.exercise.create({
data: {
userId: u.id,
name: 'Imported Bench',
type: 'barbell',
muscleGroups: '["chest"]',
isCustom: true, // user's own custom version
},
});
getCurrentUserMock.mockResolvedValue(u);
const res = await importPost(jsonReq({ payload: sampleExport() }));
const body = await res.json();
expect(body.exercisesCreated).toBe(1); // only Squat
expect(body.exercisesSkipped).toBe(1); // Bench already existed
// The user's custom row was NOT overwritten.
const bench = await prisma.exercise.findUnique({
where: { userId_name: { userId: u.id, name: 'Imported Bench' } },
});
expect(bench?.isCustom).toBe(true);
// Workouts were still created and bound to the user's existing
// (custom) Bench, not the imported one.
const sets = await prisma.setLog.findMany({
where: { exerciseId: bench!.id },
});
expect(sets.length).toBeGreaterThan(0);
});
it('replace mode requires explicit confirmation', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await importPost(
jsonReq({ payload: sampleExport(), mode: 'replace' }),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/REPLACE/);
});
it('replace mode wipes existing user-owned data first', async () => {
const u = await makeUser({ email: 'a@x' });
const existing = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Old Custom',
type: 'barbell',
muscleGroups: '[]',
},
});
await prisma.workout.create({
data: {
userId: u.id,
date: new Date('2026-01-01'),
setLogs: { create: [{ exerciseId: existing.id, setNumber: 1, reps: 1 }] },
},
});
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(1);
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
getCurrentUserMock.mockResolvedValue(u);
const res = await importPost(
jsonReq({
payload: sampleExport(),
mode: 'replace',
confirm: 'REPLACE',
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.mode).toBe('replace');
// Old custom is gone; new imported data is in place.
expect(
await prisma.exercise.findUnique({
where: { userId_name: { userId: u.id, name: 'Old Custom' } },
}),
).toBeNull();
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(2);
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(2);
});
it('keeps imports scoped to the actor, never to other users', async () => {
const me = await makeUser({ email: 'me@x' });
const other = await makeUser({ email: 'other@x' });
await prisma.exercise.create({
data: {
userId: other.id,
name: 'Other User Exercise',
type: 'barbell',
muscleGroups: '[]',
},
});
getCurrentUserMock.mockResolvedValue(me);
await importPost(jsonReq({ payload: sampleExport() }));
// The other user's data is completely untouched.
expect(await prisma.exercise.count({ where: { userId: other.id } })).toBe(1);
expect(await prisma.workout.count({ where: { userId: other.id } })).toBe(0);
expect(
(await prisma.exercise.findFirst({ where: { userId: other.id } }))?.name,
).toBe('Other User Exercise');
});
});
+10 -1
View File
@@ -25,7 +25,11 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
RUN apk add --no-cache openssl # openssl: Prisma engine runtime
# python3 + make + g++: native node-gyp builds (bcrypt). Even when the
# `bcrypt` npm package ships musl prebuilts, a postinstall fallback
# compile is the safety net — no compile, no boot.
RUN apk add --no-cache openssl python3 make g++
COPY proof-of-work/package.json proof-of-work/package-lock.json ./ COPY proof-of-work/package.json proof-of-work/package-lock.json ./
RUN npm ci RUN npm ci
@@ -66,6 +70,11 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# Native bcrypt binding. Next.js standalone tracing usually picks up
# the .node file but we copy explicitly as a belt-and-braces guard —
# bundling failures here surface as auth being silently broken at boot.
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcrypt ./node_modules/bcrypt
# Empty-schema fallback DB (used only when no baked seed is available on a # Empty-schema fallback DB (used only when no baked seed is available on a
# brand-new sideload). # brand-new sideload).
COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db