v1.2.0:1 — upgrade to Next.js 15 / React 19
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled

Closes the remaining P1: move off Next 14 onto the CVE-patched Next 15
line (15.5.x), eliminating the framework's RSC DoS/source-exposure
advisories and the middleware-auth-bypass class that applied to the 14.x
auth gate. App Router on Next 15 requires React 19, so react/react-dom
move to 19.x in lockstep; lucide-react and next-themes bump to their
React-19-compatible releases.

The code surface was the Next 15 async-request-API change: params and
searchParams are now Promises. All [id] route handlers (10 files) and the
four server pages that read them now await the resolved value, using a
uniform re-derive idiom that leaves handler bodies untouched. cookies()/
headers() were already awaited, so no other request-API changes were
needed; all routes stay dynamic, so the uncached-by-default change is a
no-op. next.config.js (static CSP) and the middleware matcher are
unchanged. No schema, no API contract change, no data migration.

Verified: tsc + lint clean, 209 tests pass, next build succeeds with the
standalone bundle tracing the Prisma engine.
This commit is contained in:
Keysat
2026-06-13 00:29:47 -05:00
parent 96d8431de9
commit f487204b73
23 changed files with 776 additions and 628 deletions
@@ -12,8 +12,9 @@ import { activate } from '@/lib/ai/activateConfig';
*/
export async function POST(
_req: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -16,8 +16,9 @@ import { isCustomUrlProvider } from '@/lib/ai/providers';
export async function GET(
_req: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -54,8 +55,9 @@ const patchSchema = z.object({
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -122,8 +124,9 @@ export async function PATCH(
export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -15,8 +15,9 @@ import { prisma } from '@/lib/prisma';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -29,8 +30,9 @@ export async function GET(
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -29,8 +29,9 @@ export const dynamic = 'force-dynamic';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -48,8 +48,9 @@ async function loadAndCheck(
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user)
@@ -82,8 +83,9 @@ export async function PATCH(
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user)
@@ -22,8 +22,9 @@ import { z } from "zod";
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -100,8 +101,9 @@ const updateExerciseSchema = z.object({
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -166,8 +168,9 @@ export async function PATCH(
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -22,8 +22,9 @@ const bodySchema = z.object({
export async function POST(
request: NextRequest,
{ params }: { params: { id: string; dayId: string } },
context: { params: Promise<{ id: string; dayId: string }> },
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+6 -3
View File
@@ -54,8 +54,9 @@ const patchSchema = z.object({
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const program = await getProgramById(user.id, params.id);
@@ -67,8 +68,9 @@ export async function GET(
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -183,8 +185,9 @@ export async function PATCH(
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } },
context: { params: Promise<{ id: string }> },
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+6 -3
View File
@@ -7,8 +7,9 @@ import { z } from "zod";
// GET: Get workout by ID
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -75,8 +76,9 @@ const updateWorkoutSchema = z.object({
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -199,8 +201,9 @@ export async function PATCH(
// DELETE: Delete workout
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -26,8 +26,9 @@ const addSetsSchema = z.object({
// POST: Add an exercise's sets to an existing workout
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -110,8 +111,9 @@ export async function POST(
// DELETE: Remove all sets for a specific exercise from a workout
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
context: { params: Promise<{ id: string }> }
) {
const params = await context.params;
try {
const user = await getCurrentUser();
if (!user) {
@@ -25,11 +25,10 @@ export const dynamic = 'force-dynamic';
* applied → "View applied program" link
* failed → error + raw response details
*/
export default async function GenerationDetailPage({
params,
}: {
params: { id: string };
export default async function GenerationDetailPage(props: {
params: Promise<{ id: string }>;
}) {
const params = await props.params;
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
@@ -14,11 +14,10 @@ export const dynamic = 'force-dynamic';
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export default async function ProgramDetailPage({
params,
}: {
params: { id: string };
export default async function ProgramDetailPage(props: {
params: Promise<{ id: string }>;
}) {
const params = await props.params;
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
+3 -4
View File
@@ -11,11 +11,10 @@ export const metadata = {
description: "Log a new workout",
};
export default async function NewWorkoutPage({
searchParams,
}: {
searchParams: { edit?: string };
export default async function NewWorkoutPage(props: {
searchParams: Promise<{ edit?: string }>;
}) {
const searchParams = await props.searchParams;
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
+3 -2
View File
@@ -8,7 +8,7 @@ import WorkoutsList from "@/components/workouts/WorkoutsList";
const PAGE_SIZE = 50;
interface PageProps {
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
searchParams: Promise<{ q?: string; dateFrom?: string; dateTo?: string }>;
}
export const metadata = {
@@ -18,7 +18,8 @@ export const metadata = {
export const dynamic = "force-dynamic";
export const revalidate = 0;
export default async function WorkoutsPage({ searchParams }: PageProps) {
export default async function WorkoutsPage(props: PageProps) {
const searchParams = await props.searchParams;
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+644 -557
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -22,12 +22,12 @@
"autoprefixer": "^10.4.16",
"bcrypt": "^6.0.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
"next": "^14.0.0",
"next-themes": "^0.2.1",
"lucide-react": "^1.18.0",
"next": "^15.5.9",
"next-themes": "^0.4.6",
"postcss": "^8.4.31",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^2.2.0",
"tailwindcss": "^3.3.0",
"zod": "^3.22.4"
@@ -35,11 +35,11 @@
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/node": "^20.5.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.35",
"eslint-config-next": "^15.5.9",
"prisma": "^5.0.0",
"tsx": "^4.7.0",
"typescript": "^5.2.2",
@@ -122,7 +122,7 @@ describe('PATCH /api/ai/templates/[id]', () => {
{ name: 'tampered' },
'PATCH',
),
{ params: { id: builtin.id } },
{ params: Promise.resolve({ id: builtin.id }) },
);
expect(res.status).toBe(403);
});
@@ -147,7 +147,7 @@ describe('PATCH /api/ai/templates/[id]', () => {
{ name: 'admin-edited' },
'PATCH',
),
{ params: { id: builtin.id } },
{ params: Promise.resolve({ id: builtin.id }) },
);
expect(res.status).toBe(200);
const updated = await prisma.aIPromptTemplate.findUnique({
@@ -178,7 +178,7 @@ describe('PATCH /api/ai/templates/[id]', () => {
{ name: 'bob hacks' },
'PATCH',
),
{ params: { id: aliceTpl.id } },
{ params: Promise.resolve({ id: aliceTpl.id }) },
);
expect(res.status).toBe(403);
});
@@ -200,7 +200,7 @@ describe('DELETE /api/ai/templates/[id]', () => {
getCurrentUserMock.mockResolvedValue(me);
const res = await deleteTemplate(
jsonReq(`http://x/api/ai/templates/${tpl.id}`, undefined, 'DELETE'),
{ params: { id: tpl.id } },
{ params: Promise.resolve({ id: tpl.id }) },
);
expect(res.status).toBe(200);
expect(await prisma.aIPromptTemplate.count()).toBe(0);
@@ -78,7 +78,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
it('returns 401 unauthenticated', async () => {
getCurrentUserMock.mockResolvedValue(null);
const res = await getExerciseDetail(req('http://x/api/exercises/anything'), {
params: { id: 'anything' },
params: Promise.resolve({ id: 'anything' }),
});
expect(res.status).toBe(401);
});
@@ -94,7 +94,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
});
getCurrentUserMock.mockResolvedValue(me);
const res = await getExerciseDetail(req('http://x/api/exercises/' + exercise.id), {
params: { id: exercise.id },
params: Promise.resolve({ id: exercise.id }),
});
expect(res.status).toBe(404);
});
@@ -108,7 +108,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
getCurrentUserMock.mockResolvedValue(user);
const res = await getExerciseDetail(
req(`http://x/api/exercises/${exercise.id}?offset=0&limit=10`),
{ params: { id: exercise.id } },
{ params: Promise.resolve({ id: exercise.id }) },
);
expect(res.status).toBe(200);
const body = await res.json();
@@ -135,7 +135,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
const r1 = await (
await getExerciseDetail(
req(`http://x/api/exercises/${exercise.id}?offset=0&limit=10`),
{ params: { id: exercise.id } },
{ params: Promise.resolve({ id: exercise.id }) },
)
).json();
expect(r1.history).toHaveLength(10);
@@ -144,7 +144,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
const r2 = await (
await getExerciseDetail(
req(`http://x/api/exercises/${exercise.id}?offset=10&limit=10`),
{ params: { id: exercise.id } },
{ params: Promise.resolve({ id: exercise.id }) },
)
).json();
expect(r2.history).toHaveLength(8); // 18 - 10
@@ -168,7 +168,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
getCurrentUserMock.mockResolvedValue(user);
const res = await getExerciseDetail(
req(`http://x/api/exercises/${exercise.id}?offset=0&limit=25`),
{ params: { id: exercise.id } },
{ params: Promise.resolve({ id: exercise.id }) },
);
const body = await res.json();
expect(body.history).toHaveLength(1);
@@ -200,7 +200,7 @@ describe('GET /api/exercises/[id] (paginated history)', () => {
getCurrentUserMock.mockResolvedValue(user);
const res = await getExerciseDetail(
req(`http://x/api/exercises/${exercise.id}`),
{ params: { id: exercise.id } },
{ params: Promise.resolve({ id: exercise.id }) },
);
const body = await res.json();
expect(body.history).toHaveLength(4);
+6 -6
View File
@@ -232,7 +232,7 @@ describe('GET /api/programs + GET /api/programs/[id]', () => {
const detail = await (
await getProgram(jsonReq(`http://x/api/programs/${programId}`), {
params: { id: programId },
params: Promise.resolve({ id: programId }),
})
).json();
expect(detail.weeks[0].days[0].exercises[0].exercise.name).toBe('Bench');
@@ -259,7 +259,7 @@ describe('GET /api/programs + GET /api/programs/[id]', () => {
getCurrentUserMock.mockResolvedValue(bob);
const res = await getProgram(
jsonReq(`http://x/api/programs/${aliceProg.id}`),
{ params: { id: aliceProg.id } },
{ params: Promise.resolve({ id: aliceProg.id }) },
);
expect(res.status).toBe(404);
});
@@ -318,7 +318,7 @@ describe('PATCH /api/programs/[id] (replace tree)', () => {
},
'PATCH',
),
{ params: { id: created.id } },
{ params: Promise.resolve({ id: created.id }) },
);
expect(patchRes.status).toBe(200);
@@ -377,7 +377,7 @@ describe('DELETE /api/programs/[id]', () => {
const res = await deleteProgram(
jsonReq(`http://x/api/programs/${created.id}`, undefined, 'DELETE'),
{ params: { id: created.id } },
{ params: Promise.resolve({ id: created.id }) },
);
expect(res.status).toBe(200);
expect(await prisma.programWeek.count()).toBe(0);
@@ -427,7 +427,7 @@ describe('POST /api/programs/[id]/days/[dayId]/start', () => {
`http://x/api/programs/${created.id}/days/${day!.id}/start`,
{},
),
{ params: { id: created.id, dayId: day!.id } },
{ params: Promise.resolve({ id: created.id, dayId: day!.id }) },
);
expect(startRes.status).toBe(201);
const workout = await startRes.json();
@@ -485,7 +485,7 @@ describe('POST /api/programs/[id]/days/[dayId]/start', () => {
jsonReq(
`http://x/api/programs/${aliceProg.id}/days/${aliceDay!.id}/start`,
),
{ params: { id: aliceProg.id, dayId: aliceDay!.id } },
{ params: Promise.resolve({ id: aliceProg.id, dayId: aliceDay!.id }) },
);
expect(res.status).toBe(404);
});