diff --git a/package-lock.json b/package-lock.json index 069a8d3bd..1d1f671eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5029,7 +5029,6 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/@mikro-orm/sqlite/-/sqlite-7.2.1.tgz", "integrity": "sha512-+iAPGORAqLl+TcBF+JSLOfRq+PwTQG1NLts+AvN9D8C2czlbDpyjyeaAFSR+5wJlMEZ4wv2rdzt4jSS5T7x3XQ==", - "dev": true, "license": "MIT", "dependencies": { "@mikro-orm/sql": "7.2.1", @@ -10032,6 +10031,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/bundle-name": { @@ -10489,21 +10489,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/concurrently": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", @@ -16264,14 +16249,13 @@ } }, "node_modules/multer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", - "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -20364,12 +20348,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -22286,11 +22264,13 @@ "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", "@mikro-orm/cli": "^7.2.1", + "@mikro-orm/core": "^7.2.1", "@mikro-orm/migrations": "^7.2.1", "@mikro-orm/nestjs": "^7.1.0", "@mikro-orm/postgresql": "^7.2.1", "@mikro-orm/seeder": "^7.2.1", "@mikro-orm/sql": "^7.2.1", + "@mikro-orm/sqlite": "^7.2.1", "@modelcontextprotocol/sdk": "^1.30.0", "@nestjs/common": "^11.2.5", "@nestjs/config": "^4.0.4", @@ -22318,6 +22298,7 @@ "json5": "^2.2.3", "jsonwebtoken": "^9.0.3", "kysely": "^0.29.6", + "multer": "^2.4.0", "nestjs-zod": "^5.5.0", "nodemailer": "^10.0.13", "otplib": "^12.0.1", @@ -22339,9 +22320,7 @@ "devDependencies": { "@aws-lite/s3-types": "^0.2.8", "@eslint/js": "^10.0.1", - "@mikro-orm/core": "^7.2.1", "@mikro-orm/entity-generator": "^7.2.1", - "@mikro-orm/sqlite": "^7.2.1", "@nestjs/testing": "^11.2.5", "@swc/core": "^1.16.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", diff --git a/server/package.json b/server/package.json index 348f3856f..d867de8b4 100644 --- a/server/package.json +++ b/server/package.json @@ -38,11 +38,13 @@ "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", "@mikro-orm/cli": "^7.2.1", + "@mikro-orm/core": "^7.2.1", "@mikro-orm/migrations": "^7.2.1", "@mikro-orm/nestjs": "^7.1.0", "@mikro-orm/postgresql": "^7.2.1", "@mikro-orm/seeder": "^7.2.1", "@mikro-orm/sql": "^7.2.1", + "@mikro-orm/sqlite": "^7.2.1", "@modelcontextprotocol/sdk": "^1.30.0", "@nestjs/common": "^11.2.5", "@nestjs/config": "^4.0.4", @@ -70,6 +72,7 @@ "json5": "^2.2.3", "jsonwebtoken": "^9.0.3", "kysely": "^0.29.6", + "multer": "^2.4.0", "nestjs-zod": "^5.5.0", "nodemailer": "^10.0.13", "otplib": "^12.0.1", @@ -93,7 +96,7 @@ "@hono/node-server": "^1.19.13", "picomatch": "^4.0.4", "ip-address": "^10.1.1", - "multer": "^2.3.0", + "multer": "^2.4.0", "ws": "^8.21.3", "qs": "^6.15.2", "file-type": "^21.3.4" @@ -101,9 +104,7 @@ "devDependencies": { "@aws-lite/s3-types": "^0.2.8", "@eslint/js": "^10.0.1", - "@mikro-orm/core": "^7.2.1", "@mikro-orm/entity-generator": "^7.2.1", - "@mikro-orm/sqlite": "^7.2.1", "@nestjs/testing": "^11.2.5", "@swc/core": "^1.16.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", diff --git a/server/tests/unit/production-dependencies.test.ts b/server/tests/unit/production-dependencies.test.ts new file mode 100644 index 000000000..3e7d90b6d --- /dev/null +++ b/server/tests/unit/production-dependencies.test.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// The image installs the server with `npm ci --omit=dev` (Dockerfile), so a +// package that src/ imports at runtime but package.json lists under +// devDependencies is simply absent in production: the container boots, loads +// dist/index.js and dies with MODULE_NOT_FOUND on the first require. Nothing +// else catches that: typecheck, lint and every test run against a full +// node_modules. This happened twice on the ORM branch (`tsx`, then +// `@mikro-orm/sqlite`), so the rule is pinned here: everything src/ (and the +// one script the image runs from source) imports must be a dependency. +const serverRoot = path.resolve(__dirname, '../..'); +const manifest = JSON.parse(fs.readFileSync(path.join(serverRoot, 'package.json'), 'utf8')) as { + dependencies: Record; + devDependencies: Record; +}; + +const builtins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + +/** The npm package a specifier resolves to: `@scope/name` or the first segment. */ +const packageOf = (specifier: string): string => + specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]; + +/** Runtime imports only: `import type` and `export type` load nothing. */ +const IMPORT_FROM = /^[ \t]*(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/gm; +const BARE_IMPORT = /^[ \t]*import\s*['"]([^'"]+)['"]/gm; +const REQUIRE_OR_DYNAMIC = /\b(?:require|import)\(\s*['"]([^'"]+)['"]\s*\)/g; + +/** Comments go first so a docblock that mentions `require('x')` is not an import. */ +const stripComments = (source: string): string => + source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, ''); + +function externalImports(file: string): string[] { + const source = stripComments(fs.readFileSync(file, 'utf8')); + const specifiers = [IMPORT_FROM, BARE_IMPORT, REQUIRE_OR_DYNAMIC].flatMap((re) => + [...source.matchAll(re)].map((m) => m[1]), + ); + return specifiers.filter((s) => !s.startsWith('.') && !s.startsWith('/') && !builtins.has(s)).map(packageOf); +} + +function* walk(dir: string): Generator { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (/\.ts$/.test(entry.name) && !/\.(test|d)\.ts$/.test(entry.name)) yield full; + } +} + +describe('production dependencies', () => { + const files = [...walk(path.join(serverRoot, 'src')), path.join(serverRoot, 'scripts', 'migrate-encryption.ts')]; + const usedBy = new Map(); + for (const file of files) { + for (const pkg of externalImports(file)) { + const list = usedBy.get(pkg) ?? []; + if (list.length < 3) list.push(path.relative(serverRoot, file)); + usedBy.set(pkg, list); + } + } + + it('PRODDEPS-001: the scan sees the server (guards against matching nothing)', () => { + expect(files.length).toBeGreaterThan(100); + expect(usedBy.has('@nestjs/common')).toBe(true); + expect(usedBy.has('@mikro-orm/sqlite')).toBe(true); + }); + + it('PRODDEPS-002: every package src/ imports at runtime is a dependency, never a devDependency or undeclared', () => { + // `@trek/shared` is the workspace package the image copies in as shared/dist; + // `trek-plugin-sdk` is resolved inside the plugin child from its own install. + const provided = new Set(['@trek/shared', 'trek-plugin-sdk']); + const missing = [...usedBy.entries()] + .filter(([pkg]) => !provided.has(pkg) && !(pkg in manifest.dependencies)) + .map( + ([pkg, where]) => + `${pkg} (${pkg in manifest.devDependencies ? 'devDependencies' : 'undeclared'}; e.g. ${where.join(', ')})`, + ); + expect(missing).toEqual([]); + }); +});