v1.2.0:1 — upgrade to Next.js 15 / React 19
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:
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Vendored
+2
-1
@@ -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.
|
||||
|
||||
Generated
+644
-557
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user