990f5582b8
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.
133 lines
3.4 KiB
TypeScript
133 lines
3.4 KiB
TypeScript
import bcrypt from "bcrypt";
|
|
import { randomBytes } from "node:crypto";
|
|
import { prisma } from "./prisma";
|
|
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
|
import { cookies } from "next/headers";
|
|
import { User } from "@prisma/client";
|
|
|
|
/**
|
|
* 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> {
|
|
return bcrypt.hash(password, 10);
|
|
}
|
|
|
|
export async function verifyPassword(
|
|
password: string,
|
|
hash: string,
|
|
): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
/**
|
|
* Create a session token for a user (30-day expiration).
|
|
*
|
|
* Token is 256 bits of CSPRNG output, hex-encoded (64 chars). Do not
|
|
* weaken this — predictable tokens enable cross-user impersonation,
|
|
* and under multi-user that means anyone-can-be-anyone if guessable.
|
|
*/
|
|
export async function createSession(
|
|
userId: string
|
|
): Promise<{ token: string; expiresAt: Date }> {
|
|
const token = randomBytes(32).toString("hex");
|
|
|
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
|
|
|
// Create the session and stamp the user's lastLoginAt in the same
|
|
// transaction. Surfaced in the admin Users table so admins can spot
|
|
// dormant accounts.
|
|
await prisma.$transaction([
|
|
prisma.session.create({
|
|
data: {
|
|
token,
|
|
userId,
|
|
expiresAt,
|
|
},
|
|
}),
|
|
prisma.user.update({
|
|
where: { id: userId },
|
|
data: { lastLoginAt: new Date() },
|
|
}),
|
|
]);
|
|
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
/**
|
|
* Validate a session token and return the associated user
|
|
*/
|
|
export async function validateSession(token: string): Promise<User | null> {
|
|
const session = await prisma.session.findUnique({
|
|
where: { token },
|
|
include: { user: true },
|
|
});
|
|
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
// Check if session has expired
|
|
if (session.expiresAt < new Date()) {
|
|
await prisma.session.delete({ where: { token } });
|
|
return null;
|
|
}
|
|
|
|
return session.user;
|
|
}
|
|
|
|
/**
|
|
* Delete a session token
|
|
*/
|
|
export async function deleteSession(token: string): Promise<void> {
|
|
await prisma.session.delete({
|
|
where: { token },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoke every session for a user except the optional `keepToken`.
|
|
* Used by password-change to log the user out of every other device
|
|
* when they rotate their password (defense against compromised
|
|
* sessions).
|
|
*/
|
|
export async function deleteOtherSessions(
|
|
userId: string,
|
|
keepToken: string | null,
|
|
): Promise<number> {
|
|
const result = await prisma.session.deleteMany({
|
|
where: {
|
|
userId,
|
|
...(keepToken ? { NOT: { token: keepToken } } : {}),
|
|
},
|
|
});
|
|
return result.count;
|
|
}
|
|
|
|
/**
|
|
* Get session from cookies object
|
|
*/
|
|
export async function getSessionFromCookies(
|
|
cookieStore: ReadonlyRequestCookies
|
|
): Promise<User | null> {
|
|
const sessionToken = cookieStore.get("sessionToken")?.value;
|
|
|
|
if (!sessionToken) {
|
|
return null;
|
|
}
|
|
|
|
return validateSession(sessionToken);
|
|
}
|
|
|
|
/**
|
|
* Get the current user from request cookies
|
|
*/
|
|
export async function getCurrentUser(): Promise<User | null> {
|
|
const cookieStore = await cookies();
|
|
return getSessionFromCookies(cookieStore);
|
|
}
|