Sfoglia il codice sorgente

Update next, better-auth and the packages Dependabot flags (#492)

* Update next, better-auth and the packages Dependabot flags

next 16.3.6 to 16.4.2, the newest release next-ws 2.2.19 supports, for the
Image Optimization and use cache remote code execution fixes among others.
next-ws 2.2.19 now waits for the lazily loaded route module itself, so our
own patch for that is removed; the runtime image checks next-ws for it.

better-auth and @better-auth/passkey 1.6.29 to 1.7.15. The account schema
is unchanged from 1.6 since 1.7.3, so no migration. enableTwoFactor answers
with a method now, and the account page asks for TOTP and reads the QR and
backup codes from that answer only.

stream-json 1.8 to 3.8 for its prototype pollution fix. It is ESM with
lowercase paths, so the backup reader imports the default parser stream and
feeds tokens to the Assembler itself. Its test runs under Node, where a
Buffer is a Uint8Array, and now proves a __proto__ key stays data.

nodemailer 10.3.1, zod 4.6.7, vitest 4.1.12, and the transitive fixes npm
audit can make without a major bump (dompurify, undici, follow-redirects,
hono, fast-uri, qs, body-parser, ip-address). The lockfile was written with
npm 11: npm 10 crashes resolving vitest's optional peers, while npm ci on
npm 10 installs from it fine.

* Give the permission test's database an audit log, so no write fails after the file ends

vitest 4.1.12 reports a console call that arrives after its worker closed as an unhandled error. The actions here write their audit line without waiting, and with no auditLog on the mocked db each write failed and logged late.
Bernt Christian Egeland 15 ore fa
parent
commit
ddc3ee0a33

+ 7 - 8
Dockerfile

@@ -8,10 +8,7 @@ WORKDIR /app
 COPY package.json package-lock.json* ./
 COPY prisma ./prisma/
 COPY prisma.config.ts ./prisma.config.ts
-# npm ci runs the prepare script, which patches next-ws and then applies our
-# own follow-up patch from scripts/. The source tree is not copied until the
-# builder stage, so that one file has to come along here.
-COPY scripts/patch-next-ws-first-upgrade.mjs ./scripts/
+# npm ci runs the prepare script, which patches next for next-ws.
 RUN npm ci
 
 # Rebuild the source code only when needed
@@ -83,8 +80,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated
 
 # The server runs on the node_modules Next traced for it. That is exactly what
-# the build ran with: next carrying the next-ws patch and the first-upgrade fix
-# the prepare script applied in the deps stage, and sharp with the binary for
+# the build ran with: next carrying the next-ws patch the prepare script
+# applied in the deps stage, and sharp with the binary for
 # this image's platform. Nothing is installed over it, so none of that has to
 # be patched or resolved a second time here.
 #
@@ -104,10 +101,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./tools/src/generat
 # patch, with next-ws waiting for the lazily loaded route module; a native
 # module that cannot be loaded throws while its route is being evaluated,
 # which reaches the browser as an empty HTTP 500 with nothing to explain it.
-COPY --from=builder --chown=nextjs:nodejs /app/scripts/patch-next-ws-first-upgrade.mjs ./scripts/patch-next-ws-first-upgrade.mjs
 RUN grep -qs "next-ws" node_modules/next/dist/server/next-server.js \
       || { echo "the traced next is missing the next-ws patch"; exit 1; }
-RUN node scripts/patch-next-ws-first-upgrade.mjs
+# next-ws 2.2.19 waits for the route module itself (ensureUserland); before
+# that, scripts/ carried a patch for it, and an older next-ws must not slip back in.
+RUN grep -qs "ensureUserland" node_modules/next-ws/dist/server/index.cjs \
+      || { echo "the traced next-ws does not wait for the lazily loaded route module"; exit 1; }
 RUN node -e "require('sharp'); console.log('sharp loads')"
 
 # Copy init script

File diff suppressed because it is too large
+ 212 - 226
package-lock.json


+ 10 - 11
package.json

@@ -12,7 +12,7 @@
     "format": "biome format . --write",
     "check": "biome check .",
     "check:fix": "biome check . --write",
-    "prepare": "next-ws patch --yes && node scripts/patch-next-ws-first-upgrade.mjs",
+    "prepare": "next-ws patch --yes",
     "test": "vitest run",
     "test:coverage": "vitest --coverage",
     "test:db": "vitest run --config vitest.db.config.ts",
@@ -23,7 +23,7 @@
   "dependencies": {
     "@aws-sdk/client-s3": "^3.1159.0",
     "@aws-sdk/client-ses": "^3.1045.0",
-    "@better-auth/passkey": "1.6.29",
+    "@better-auth/passkey": "1.7.15",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
@@ -54,7 +54,7 @@
     "@zumer/snapdom": "^2.23.1",
     "archiver": "^8.0.0",
     "axios": "^1.20.0",
-    "better-auth": "1.6.29",
+    "better-auth": "1.7.15",
     "bson": "^7.2.0",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
@@ -68,11 +68,11 @@
     "iconv-lite": "^0.7.3",
     "lucide-react": "^0.563.0",
     "mailgun.js": "^12.7.1",
-    "next": "16.3.6",
+    "next": "16.4.2",
     "next-intl": "^4.14.2",
     "next-themes": "^0.4.6",
-    "next-ws": "^2.2.16",
-    "nodemailer": "^10.0.13",
+    "next-ws": "^2.2.19",
+    "nodemailer": "^10.3.1",
     "openai": "^6.27.0",
     "papaparse": "^5.7.0",
     "pdf-lib": "^1.17.1",
@@ -95,13 +95,13 @@
     "resend": "^6.14.0",
     "sharp": "^0.35.5",
     "sonner": "^2.0.7",
-    "stream-json": "^1.8.0",
+    "stream-json": "^3.8.0",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",
     "vaul": "^1.1.2",
     "ws": "^8.21.0",
     "yauzl": "^3.4.0",
-    "zod": "^4.3.6",
+    "zod": "^4.6.7",
     "zustand": "^5.0.11"
   },
   "devDependencies": {
@@ -120,10 +120,9 @@
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/smtp-server": "^3.5.13",
-    "@types/stream-json": "^1.7.8",
     "@types/ws": "^8.18.1",
     "@types/yauzl": "^3.4.0",
-    "@vitest/coverage-v8": "^4.1.11",
+    "@vitest/coverage-v8": "^4.1.12",
     "jsdom": "^30.0.1",
     "jszip": "^3.10.1",
     "mailparser": "^3.9.33",
@@ -134,7 +133,7 @@
     "tw-animate-css": "^1.4.0",
     "typescript": "^5",
     "unpdf": "^1.8.1",
-    "vitest": "^4.1.11"
+    "vitest": "^4.1.12"
   },
   "overrides": {
     "fast-xml-parser": ">=5.3.6",

+ 0 - 32
scripts/patch-next-ws-first-upgrade.mjs

@@ -1,32 +0,0 @@
-/**
- * Make next-ws wait for a route module before reading its exports.
- *
- * Next 16.3 loads route modules lazily: `routeModule.userland` throws
- * "The lazy module is still loading" until `ensureUserland()` has resolved.
- * next-ws 2.2.14 reads `userland` the moment an upgrade request arrives, so
- * the first WebSocket connection after every boot died as an unhandled
- * rejection and the browser had to reconnect. This runs after `next-ws patch`,
- * both locally and in the runtime image, and fails loudly when the code it
- * expects is not there, so a next-ws bump cannot skip it quietly.
- */
-import { readFileSync, writeFileSync } from 'node:fs'
-import { createRequire } from 'node:module'
-
-const require = createRequire(`${process.cwd()}/`)
-const file = require.resolve('next-ws/server')
-const source = readFileSync(file, 'utf8')
-
-const anchor = '    const handleUpgrade = module2.userland.UPGRADE;\n'
-const fix = '    if (typeof module2.ensureUserland === "function") await module2.ensureUserland();\n'
-
-if (source.includes(fix)) {
-  console.log(`[next-ws] first-upgrade fix already applied to ${file}`)
-} else if (source.includes(anchor)) {
-  writeFileSync(file, source.replace(anchor, fix + anchor))
-  console.log(`[next-ws] first-upgrade fix applied to ${file}`)
-} else {
-  console.error(
-    `[next-ws] could not apply the first-upgrade fix: ${file} no longer contains the expected upgrade handler. Check whether the installed next-ws already awaits ensureUserland() and update scripts/patch-next-ws-first-upgrade.mjs.`
-  )
-  process.exit(1)
-}

+ 4 - 0
src/__tests__/auth/permission-enforcement.test.ts

@@ -62,6 +62,10 @@ vi.mock('@/lib/features', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
+    // Actions write their audit line without waiting for it. Without this the
+    // write failed and logged after the file had finished, which vitest
+    // reports as an unhandled error once the worker has closed.
+    auditLog: { create: vi.fn().mockResolvedValue({}) },
     vehicle: {
       findMany: vi.fn(),
       findFirst: vi.fn(),

+ 31 - 1
src/__tests__/lib/backup/zip-file.test.ts

@@ -1,15 +1,27 @@
 /**
+ * @vitest-environment node
+ *
  * The streamed zip reader: the upload lands on disk, the table of contents is
  * checked before anything is inflated, data is parsed straight from the
  * archive, and an entry that tries to leave the archive fails it.
+ *
+ * Server code, so Node's environment: under jsdom a Buffer is not an instance
+ * of the test realm's Uint8Array, and the JSON parser refuses it.
  */
 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import { createWriteStream } from 'fs'
+import { Readable } from 'stream'
 import { mkdtemp, readFile, rm, stat } from 'fs/promises'
 import os from 'os'
 import path from 'path'
 import { ZipArchive } from 'archiver'
-import { extractEntry, openZip, readEntryJson, spoolUpload } from '@/lib/backup/zip-file'
+import {
+  extractEntry,
+  openZip,
+  parseJsonStream,
+  readEntryJson,
+  spoolUpload,
+} from '@/lib/backup/zip-file'
 
 let sandbox: string
 
@@ -206,3 +218,21 @@ describe('openZip', () => {
     await expect(openZip(file)).rejects.toThrow()
   })
 })
+
+describe('parseJsonStream', () => {
+  it('reads JSON split across chunks', async () => {
+    const data = await parseJsonStream(Readable.from(['{"customers":[{"na', 'me":"Ada"}],"n":2}']))
+    expect(data).toEqual({ customers: [{ name: 'Ada' }], n: 2 })
+  })
+
+  it('keeps a __proto__ key as data, the way JSON.parse does', async () => {
+    // A backup is a file someone uploads. A __proto__ key in it must not
+    // reach the prototype of the objects the restore builds.
+    const data = await parseJsonStream<Record<string, unknown>>(
+      Readable.from(['{"__proto__":{"polluted":true},"customers":[]}'])
+    )
+    expect(Object.hasOwn(data, '__proto__')).toBe(true)
+    expect(({} as Record<string, unknown>).polluted).toBeUndefined()
+    expect((data as { polluted?: unknown }).polluted).toBeUndefined()
+  })
+})

+ 12 - 4
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -96,13 +96,21 @@ export function AccountSettings({
     }
     setEnabling2FA(true)
     try {
-      const result = await authClient.twoFactor.enable({ password: twoFactorPassword })
+      // An authenticator app (TOTP). Better Auth 1.7 also enrols one-time
+      // codes by email, and only the TOTP answer carries the QR code and the
+      // backup codes.
+      const result = await authClient.twoFactor.enable({
+        password: twoFactorPassword,
+        method: 'totp',
+      })
       if (result.error) {
         toast.error(result.error.message || t('account.failedEnable2FA'))
-      } else {
-        setTotpURI(result.data?.totpURI || '')
-        setBackupCodes(result.data?.backupCodes || [])
+      } else if (result.data?.method === 'totp') {
+        setTotpURI(result.data.totpURI)
+        setBackupCodes(result.data.backupCodes)
         setSetupStep('qr')
+      } else {
+        toast.error(t('account.failedEnable2FA'))
       }
     } catch (err) {
       toast.error(err instanceof Error ? err.message : t('account.failedEnable2FA'))

+ 9 - 5
src/lib/backup/zip-file.ts

@@ -19,8 +19,8 @@ import { Readable, Transform } from 'stream'
 import type { ReadableStream as WebReadableStream } from 'stream/web'
 import { pipeline } from 'stream/promises'
 import yauzl from 'yauzl'
-import { parser } from 'stream-json'
-import Assembler from 'stream-json/Assembler'
+import parserStream from 'stream-json'
+import Assembler from 'stream-json/assembler.js'
 import { BACKUP_ZIP_LIMITS, assertZipWithinLimits, type ZipLimits } from './zip-guard'
 
 /** The first bytes of a zip file. */
@@ -164,15 +164,19 @@ export async function extractEntry(zip: OpenedZip, entry: ZipEntry, target: stri
   await pipeline(await zip.open(entry), createWriteStream(target))
 }
 
+/** One token of stream-json's parser, as its Assembler takes it. */
+type JsonToken = Parameters<Assembler['consume']>[0]
+
 /**
  * Parses JSON from a stream without first making it a string. The parser
  * is slower than JSON.parse, by a few seconds on a hundred megabytes, and
  * needs memory for the result alone instead of result, text and bytes.
  */
 export async function parseJsonStream<T = unknown>(source: Readable): Promise<T> {
-  const tokens = parser()
-  const assembler = Assembler.connectTo(tokens)
-  await pipeline(source, tokens)
+  const assembler = new Assembler<T>()
+  await pipeline(source, parserStream(), async (tokens: AsyncIterable<JsonToken>) => {
+    for await (const token of tokens) assembler.consume(token)
+  })
   return assembler.current as T
 }
 

Some files were not shown because too many files changed in this diff