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
+10 -9
View File
@@ -4,8 +4,8 @@ Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s
## Stack (versions that matter)
- **Next.js 14** (App Router, server components + server actions, SSE streaming)
- **React 18**, **TypeScript 5**, **TailwindCSS 3**
- **Next.js 15** (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
- **React 19**, **TypeScript 5**, **TailwindCSS 3**
- **Prisma 5** ORM over **SQLite** (WAL mode; tuned at boot)
- **bcrypt** (native — NOT bcryptjs), **zod 3** for validation
- **Vitest 4** for tests
@@ -73,6 +73,7 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
- **Malformed JSON body must return 400, not 500.** Routes whose catch maps `instanceof z.ZodError → 400` parse via `readJsonBody(request)` (`lib/http.ts` — throws a `ZodError` on bad JSON, so the existing branch handles it with no catch change). `safeParse`-style routes (`me/import`, `admin/signups`) wrap `request.json()` in an explicit `try/catch → 400`. (AI/admin routes using `.catch(() => ({}))` are a third, pre-existing pattern — unify if you touch them.)
- **Next 15 dynamic APIs are async — `await` them.** Route-handler context `params`, page/layout `params` + `searchParams`, and `cookies()`/`headers()` are all Promises. Established idiom (keeps handler bodies untouched): `[id]` routes take `context: { params: Promise<{…}> }` then `const params = await context.params`; server pages take `props` then `const params = await props.params` / `const searchParams = await props.searchParams`. Route tests pass `params: Promise.resolve({…})`. All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here.
- **The container runs the Node server as non-root.** `docker_entrypoint.sh` runs as root only to prep `/data` (seed, ALTERs, library reconcile), then `chown -R nextjs:nodejs "$DATA_DIR"` and `exec su-exec nextjs:nodejs node /app/server.js` (su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run *before* that final line.
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`.
- **Before editing the AI subsystem (`proof-of-work/lib/ai/**` or the generate/generations routes), read `docs/guides/ai-subsystem.md`** — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
@@ -98,18 +99,18 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state
Latest version is **1.1.0:9****built + sideloaded** to the StartOS box (2026-06-13, on `master`). The container privilege-drop is *verified* only once a clean boot is confirmed in StartOS → Logs (entrypoint logs `launching ... as nextjs`, app writes `/data` as uid 1001 with no permission errors). Registry empty, **publishing parked** (sideload-only via `make install`).
Latest version is **1.2.0:1**the **Next.js 14→15 / React 18→19** upgrade (the remaining P1; closes the Next framework RSC + middleware-bypass CVEs). Code complete and **verified locally**: tsc + lint clean, **209 tests pass**, `next build` succeeds, standalone bundle traces the Prisma engine. **Building the x86 s9pk + sideloading now.** Registry empty, **publishing parked** (sideload-only via `make install`).
**Pending on-box check:** confirm 1.2.0:1 boots clean in StartOS → Logs (this supersedes the still-unconfirmed 1.1.0:9 non-root clean-boot check — same Logs verification: entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 with no permission errors).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
Done this session (P2 batch from `EVALUATION.md`, reviewed by the reviewer agent): malformed bodies / invalid `date` / out-of-range pagination now **400 not 500** (new `lib/http.ts readJsonBody` across 11 CRUD routes; explicit guard on `me/import` + `admin/signups`); **`POST /api/auth` rate-limited** (shares the UI `login:${ip}` 10/15min bucket; 429+Retry-After); rate-limiter **XFF anti-spoof** (rightmost entry); **container drops root** via su-exec. Tests **209 pass**, build + tsc + lint clean.
In progress: none. Pending on-box check: confirm 1.1.0:9 boots clean and the Node server writes `/data` as non-root (StartOS → Logs).
Done this session: **Next 15 / React 19 upgrade.** Async-`params`/`searchParams` migration across 10 `[id]` route files + 4 server pages (uniform `await` re-derive idiom — see Conventions). Deps: `next` 15.5.x, `react`/`react-dom` 19.x, `eslint-config-next` 15.5.x, `lucide-react` → 1.x, `next-themes` → 0.4.x (the latter two bumped for React-19 peers). `next.config.js`/middleware unchanged; no schema/data change. Residual `npm audit` items are dev/build-only tooling (esbuild/tsx, picomatch, bundled postcss) — **not in the runtime image; do NOT `audit fix --force`** (npm wrongly suggests downgrading to `next@9`).
Next steps (priority order):
1. **Next.js 14→15 major bump** (the remaining P1 — CVEs) as its own tested change — planned next; the login server action already uses async `cookies()/headers()`, easing the migration.
2. **P3 hardening batch** (`ROADMAP.md` → Security & hardening): login timing oracle, CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership on workout PATCH/sets POST, 30-day sessions, text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start`.
3. Tiered AI prompt formatting (`ROADMAP.md` → AI quality).
1. **P3 hardening batch** (`ROADMAP.md` → Security & hardening): login timing oracle, CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership on workout PATCH/sets POST, 30-day sessions, text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start`.
2. Tiered AI prompt formatting (`ROADMAP.md` → AI quality).
3. (Later) **Next 15→16** when ready — `next lint` is deprecated in 15.5 (removed in 16), plus Next 16's own breaking changes; do it as its own tested bump.
Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). `publish.sh` Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (`ROADMAP.md` → Packaging).
@@ -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);
});
+7 -1
View File
@@ -15,12 +15,14 @@ import { v_1_1_0_6 } from './v1.1.0.6'
import { v_1_1_0_7 } from './v1.1.0.7'
import { v_1_1_0_8 } from './v1.1.0.8'
import { v_1_1_0_9 } from './v1.1.0.9'
import { v_1_2_0_1 } from './v1.2.0.1'
/**
* Version graph for the `proof-of-work` package.
*
* 1.0.0 line — feature-complete logger + multi-user + library curation.
* 1.1.0 line — Programs (manual + AI) + AI integration.
* 1.2.0 line — platform upgrade (Next.js 15 / React 19).
*
* v1.0.0:1 — initial release, seeded cutover.
* v1.0.0:2 — CSP fix.
@@ -56,9 +58,12 @@ import { v_1_1_0_9 } from './v1.1.0.9'
* v1.1.0:9 — P2 hardening: malformed-body/invalid-date/bad-pagination ->
* 400 (not 500); POST /api/auth rate-limited; rate-limiter XFF
* anti-spoof (rightmost entry); container drops root via su-exec.
* v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next
* framework RSC + middleware-bypass CVEs; async-params migration
* across all [id] routes + server pages. No schema/data change.
*/
export const versionGraph = VersionGraph.of({
current: v_1_1_0_9,
current: v_1_2_0_1,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -75,5 +80,6 @@ export const versionGraph = VersionGraph.of({
v_1_1_0_6,
v_1_1_0_7,
v_1_1_0_8,
v_1_1_0_9,
],
})
+34
View File
@@ -0,0 +1,34 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade (2026-06-13).
*
* The remaining P1 from the full-eval queue: move off Next.js 14 onto the
* CVE-patched Next 15 line (15.5.x), closing the framework's RSC
* DoS/source-exposure advisories and the middleware-auth-bypass class that
* applied to the 14.x `middleware.ts` auth gate. App Router on Next 15
* requires React 19, so react/react-dom move to 19.x in lockstep
* (lucide-react + next-themes bumped to their React-19-compatible releases).
*
* 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;
* `cookies()`/`headers()` were already awaited from the earlier auth work, so
* no other request-API changes were needed. All routes remain dynamic, so the
* Next 15 "uncached by default" change is a no-op here. next.config.js (static
* CSP) and the middleware matcher are unchanged.
*
* App-code + dependency upgrade only — no schema, no API contract change, no
* data migration. Existing /data survives untouched.
*/
export const v_1_2_0_1 = VersionInfo.of({
version: '1.2.0:1',
releaseNotes: {
en_US:
'Platform upgrade: the app now runs on Next.js 15 and React 19, picking up the framework security patches and a more current runtime. No new features and no data changes — this is a maintenance release, and your existing data is untouched.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})