Просмотр исходного кода

Merge pull request #10717 from growilabs/support/typescript-go

support: Typecheck by tsgo
mergify[bot] 2 месяцев назад
Родитель
Сommit
edd88bda63
100 измененных файлов с 2347 добавлено и 1958 удалено
  1. 5 0
      .changeset/chatty-mayflies-own.md
  2. 5 0
      .changeset/pink-geese-grab.md
  3. 2 0
      .devcontainer/app/devcontainer.json
  4. 2 7
      .serena/memories/coding_conventions.md
  5. 7 13
      .serena/memories/project_structure.md
  6. 2 7
      .serena/memories/task_completion_checklist.md
  7. 4 5
      .serena/memories/tech_stack.md
  8. 0 18
      .serena/memories/vitest-testing-tips-and-best-practices.md
  9. 1 2
      AGENTS.md
  10. 1 1
      apps/app/AGENTS.md
  11. 4 0
      apps/app/config/next-i18next.config.d.ts
  12. 0 86
      apps/app/jest.config.js
  13. 0 1
      apps/app/nodemon.json
  14. 3 10
      apps/app/package.json
  15. 3 0
      apps/app/src/client/components/Admin/Security/LdapAuthTestModal.jsx
  16. 9 5
      apps/app/src/client/components/Admin/Users/GrantAdminButton.tsx
  17. 3 0
      apps/app/src/client/components/Admin/Users/SendInvitationEmailButton.jsx
  18. 1 0
      apps/app/src/client/components/Admin/Users/StatusActivateButton.jsx
  19. 9 5
      apps/app/src/client/components/Admin/Users/UserMenu.tsx
  20. 1 0
      apps/app/src/client/components/Admin/Users/UserRemoveButton.jsx
  21. 7 4
      apps/app/src/client/components/Admin/Users/UserTable.tsx
  22. 3 1
      apps/app/src/client/components/InstallerForm.tsx
  23. 2 0
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  24. 4 2
      apps/app/src/client/components/Me/BasicInfoSettings.tsx
  25. 1 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  26. 28 14
      apps/app/src/client/components/UnstatedUtils.tsx
  27. 4 1
      apps/app/src/client/util/scope-util.ts
  28. 1 1
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantAlert.tsx
  29. 4 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  30. 5 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  31. 112 38
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.integ.ts
  32. 105 107
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.integ.ts
  33. 6 4
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  34. 16 12
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  35. 13 9
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  36. 13 11
      apps/app/src/features/openai/server/routes/delete-ai-assistant.ts
  37. 13 11
      apps/app/src/features/openai/server/routes/delete-thread.ts
  38. 19 20
      apps/app/src/features/openai/server/routes/edit/index.ts
  39. 23 12
      apps/app/src/features/openai/server/routes/get-recent-threads.ts
  40. 22 16
      apps/app/src/features/openai/server/routes/get-threads.ts
  41. 24 15
      apps/app/src/features/openai/server/routes/message/get-messages.ts
  42. 19 16
      apps/app/src/features/openai/server/routes/message/post-message.ts
  43. 4 3
      apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts
  44. 11 13
      apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts
  45. 19 12
      apps/app/src/features/openai/server/routes/thread.ts
  46. 13 11
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  47. 5 4
      apps/app/src/features/openai/server/services/openai.ts
  48. 2 3
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  49. 1 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  50. 4 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  51. 31 11
      apps/app/src/features/page-tree/components/ItemsTree.spec.tsx
  52. 2 3
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  53. 8 3
      apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.integ.ts
  54. 1 1
      apps/app/src/pages/[[...path]]/server-side-props.ts
  55. 12 8
      apps/app/src/pages/_search/use-hydrate-server-configurations.ts
  56. 13 9
      apps/app/src/pages/basic-layout-page/hydrate.ts
  57. 3 2
      apps/app/src/pages/common-props/commons.ts
  58. 5 4
      apps/app/src/pages/forgot-password/use-hydrate-server-configurations.ts
  59. 59 34
      apps/app/src/pages/general-page/hydrate.ts
  60. 13 6
      apps/app/src/pages/me/use-hydrate-server-configurations.ts
  61. 10 4
      apps/app/src/pages/trash/use-hydrate-server-configurations.ts
  62. 0 822
      apps/app/src/server/crowi/index.js
  63. 895 0
      apps/app/src/server/crowi/index.ts
  64. 3 7
      apps/app/src/server/events/activity.ts
  65. 0 12
      apps/app/src/server/events/admin.js
  66. 14 0
      apps/app/src/server/events/admin.ts
  67. 0 15
      apps/app/src/server/events/bookmark.js
  68. 22 0
      apps/app/src/server/events/bookmark.ts
  69. 0 28
      apps/app/src/server/events/page.js
  70. 35 0
      apps/app/src/server/events/page.ts
  71. 0 14
      apps/app/src/server/events/tag.js
  72. 19 0
      apps/app/src/server/events/tag.ts
  73. 6 8
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  74. 1 1
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  75. 6 8
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  76. 1 1
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  77. 5 11
      apps/app/src/server/middlewares/access-token-parser/index.ts
  78. 0 15
      apps/app/src/server/middlewares/access-token-parser/interfaces.ts
  79. 20 3
      apps/app/src/server/middlewares/admin-required.ts
  80. 1 1
      apps/app/src/server/middlewares/certify-origin.ts
  81. 6 3
      apps/app/src/server/middlewares/http-error-handler.ts
  82. 102 94
      apps/app/src/server/middlewares/login-required.spec.ts
  83. 34 9
      apps/app/src/server/middlewares/login-required.ts
  84. 0 38
      apps/app/src/server/models/GlobalNotificationSetting.ts
  85. 0 35
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  86. 44 0
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.ts
  87. 0 35
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  88. 44 0
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.ts
  89. 3 3
      apps/app/src/server/models/GlobalNotificationSetting/consts.ts
  90. 0 122
      apps/app/src/server/models/GlobalNotificationSetting/index.js
  91. 195 0
      apps/app/src/server/models/GlobalNotificationSetting/index.ts
  92. 62 0
      apps/app/src/server/models/GlobalNotificationSetting/types.d.ts
  93. 1 1
      apps/app/src/server/models/bookmark.ts
  94. 2 1
      apps/app/src/server/models/external-account.ts
  95. 7 8
      apps/app/src/server/models/obsolete-page.js
  96. 13 25
      apps/app/src/server/models/page-redirect.integ.ts
  97. 75 32
      apps/app/src/server/models/page.integ.ts
  98. 4 3
      apps/app/src/server/models/page.ts
  99. 1 1
      apps/app/src/server/models/update-post.spec.ts
  100. 4 3
      apps/app/src/server/models/user-group-relation.ts

+ 5 - 0
.changeset/chatty-mayflies-own.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': minor
+---
+
+Add AccessTokenParser type

+ 5 - 0
.changeset/pink-geese-grab.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': minor
+---
+
+Explicitly define scope grouping

+ 2 - 0
.devcontainer/app/devcontainer.json

@@ -30,6 +30,8 @@
         "editorconfig.editorconfig",
         "shinnn.stylelint",
         "stylelint.vscode-stylelint",
+        // TypeScript (Native Preview)
+        "typescriptteam.native-preview",
         // Test
         "vitest.explorer",
         "ms-playwright.playwright",

+ 2 - 7
.serena/memories/coding_conventions.md

@@ -49,13 +49,8 @@ vitest.workspace.mts の設定に基づく:
 
 ## ディレクトリ構造の規則
 - `src/`: ソースコード
-- `test/`: Jest用の古いテストファイル(廃止予定)
-- `test-with-vite/`: Vitest用の新しいテストファイル
-- `playwright/`: E2Eテストファイル
+- `test/`: Vitest用のファイル
+- `playwright/`: Playwright を用いた E2E テストファイル
 - `config/`: 設定ファイル
 - `public/`: 静的ファイル
 - `dist/`: ビルド出力
-
-## 移行ガイドライン
-- 新規開発: Biome + Vitest を使用
-- 既存コード: 段階的に Jest → Vitest に移行

+ 7 - 13
.serena/memories/project_structure.md

@@ -25,8 +25,7 @@ growi/
 ```
 apps/app/
 ├── src/                   # ソースコード
-├── test/                  # 古いJestテストファイル(廃止予定)
-├── test-with-vite/        # 新しいVitestテストファイル
+├── test/                  # Vitest用ファイル
 ├── playwright/            # E2Eテスト(Playwright)
 ├── config/                # 設定ファイル
 ├── public/                # 静的ファイル
@@ -37,24 +36,19 @@ apps/app/
 
 ## テストディレクトリの詳細
 
-### test/ (廃止予定)
-- Jest用の古いテストファイル
-- 段階的にtest-with-vite/に移行予定
-- 新規テストは作成しない
-
-### test-with-vite/
-- Vitest用の新しいテストファイル
-- 新規テストはここに作成
-- セットアップファイル: `setup/mongoms.ts` (MongoDB用)
+### test/
+- Vitest用のファイル
+- 新規テスト用のユーティリティはここに作成
+- セットアップファイル: `setup/mongo.ts` (MongoDB用)
 
 ### playwright/
-- E2Eテスト用ディレクトリ
+- Playwright による E2E テスト用ディレクトリ
 - ブラウザ操作を含むテスト
 
 ## テストファイルの配置ルール
 
 ### Vitestテストファイル
-以下のパターンでソースコードと同じディレクトリまたはtest-with-vite/配下に配置:
+以下のパターンでソースコードと同じディレクトリに配置:
 
 - **単体テスト**: `*.spec.{ts,js}`
 - **統合テスト**: `*.integ.ts` 

+ 2 - 7
.serena/memories/task_completion_checklist.md

@@ -74,16 +74,11 @@ pnpm run dev:migrate         # マイグレーション実行
 - **単体テスト**: `*.spec.{ts,js}` (Node.js環境)
 - **統合テスト**: `*.integ.ts` (Node.js + MongoDB環境)  
 - **コンポーネントテスト**: `*.spec.{tsx,jsx}` (happy-dom環境)
-- test-with-vite/ または対象ファイルと同じディレクトリに配置
-
-### 既存テストの修正
-- test/ 配下のJestテストは段階的に移行
-- 可能であればtest-with-vite/にVitestテストとして書き直し
+- 対象ファイルと同じディレクトリに配置
 
 ## コミット前の最終チェック
 1. Biome エラーが解消されているか
-2. Vitestテスト(または過渡期はJest)がパスしているか
-3. 重要な変更はPlaywright E2Eテストも実行
+2. Vitestテストがパスしているか
 4. ビルドが成功するか
 5. 変更による既存機能への影響がないか
 6. 適切なコミットメッセージを作成したか

+ 4 - 5
.serena/memories/tech_stack.md

@@ -9,11 +9,10 @@
 - **Jotai**: アトミック状態管理(推奨)
 - **SWR** ^2.3.2: データフェッチ・キャッシュ
 
-## 開発ツール移行状況
-| 従来 | 移行先 | 状況 |
-|------|--------|------|
-| ESLint | **Biome** | 新規推奨 |
-| Jest | **Vitest** + **Playwright** | 新規推奨 |
+## 開発ツール
+- **Biome**: フォーマッター、リンター
+- **Vitest**: テストフレームワーク
+- **Playwright**: e2eテストフレームワーク
 
 ## 主要コマンド
 ```bash

+ 0 - 18
.serena/memories/vitest-testing-tips-and-best-practices.md

@@ -75,21 +75,3 @@ pnpm run test:vitest:coverage
 # 特定ファイルのみ実行(coverageあり)
 pnpm run test:vitest src/path/to/test.spec.tsx
 ```
-
-### package.jsonスクリプト参照
-```json
-{
-  "scripts": {
-    "test": "run-p test:*",
-    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
-    "test:vitest": "vitest run --coverage"
-  }
-}
-```
-
-## Jest→Vitest移行要点
-- `jest.config.js` → `vitest.config.ts`
-- `@types/jest` → `vitest/globals`
-- ESModulesネイティブサポート → 高速起動・実行
-
-この設定により型安全性と保守性を両立した高品質テストが可能。

+ 1 - 2
AGENTS.md

@@ -37,11 +37,10 @@ GROWI is a team collaboration software using markdown - a wiki platform with hie
 - `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
 
 ### Testing and Quality
-- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
+- `turbo run test @apps/app` - Run Vitest test suites with coverage
 - `turbo run lint @apps/app` - Run all linters (TypeScript, Biome, Stylelint, OpenAPI)
 - `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
 - `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
-- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
 
 ### Development Utilities  
 - `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded

+ 1 - 1
apps/app/AGENTS.md

@@ -42,7 +42,7 @@ This guide provides comprehensive documentation for AI coding agents working on
   - **Unstated**: Legacy (being phased out, replaced by Jotai)
 - **Testing**: 
   - Vitest for unit tests (`*.spec.ts`, `*.spec.tsx`)
-  - Jest for integration tests (`*.integ.ts`)
+  - Vitest for integration tests (`*.integ.ts`)
   - React Testing Library for component testing
   - Playwright for E2E testing
 - **i18n**: next-i18next for internationalization

+ 4 - 0
apps/app/config/next-i18next.config.d.ts

@@ -0,0 +1,4 @@
+import type { UserConfig } from 'next-i18next';
+
+declare const config: UserConfig;
+export = config;

+ 0 - 86
apps/app/jest.config.js

@@ -1,86 +0,0 @@
-// For a detailed explanation regarding each configuration property, visit:
-// https://jestjs.io/docs/en/configuration.html
-
-const MODULE_NAME_MAPPING = {
-  '^\\^/(.+)$': '<rootDir>/$1',
-  '^~/(.+)$': '<rootDir>/src/$1',
-};
-
-module.exports = {
-  // Indicates whether each individual test should be reported during the run
-  verbose: true,
-
-  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
-
-  projects: [
-    {
-      displayName: 'server',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc-node/jest',
-      },
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: [
-        '<rootDir>/test/integration/**/*.test.ts',
-        '<rootDir>/test/integration/**/*.test.js',
-      ],
-      // https://regex101.com/r/jTaxYS/1
-      modulePathIgnorePatterns: [
-        '<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s',
-      ],
-      testEnvironment: 'node',
-      globalSetup: '<rootDir>/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-    },
-    {
-      displayName: 'server-v5',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc-node/jest',
-      },
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: [
-        '<rootDir>/test/integration/**/v5.*.test.ts',
-        '<rootDir>/test/integration/**/v5.*.test.js',
-      ],
-
-      testEnvironment: 'node',
-      globalSetup: '<rootDir>/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-    },
-  ],
-
-  // Automatically clear mock calls and instances between every test
-  clearMocks: true,
-
-  // Indicates whether the coverage information should be collected while executing the test
-  collectCoverage: true,
-
-  // An array of glob patterns indicating a set of files for which coverage information should be collected
-  // collectCoverageFrom: undefined,
-
-  // The directory where Jest should output its coverage files
-  coverageDirectory: 'coverage',
-
-  // An array of regexp pattern strings used to skip coverage collection
-  coveragePathIgnorePatterns: [
-    'index.ts',
-    '/config/',
-    '/resource/',
-    '/node_modules/',
-  ],
-};

+ 0 - 1
apps/app/nodemon.json

@@ -9,7 +9,6 @@
     "src/client",
     "src/**/client",
     "test",
-    "test-with-vite",
     "tmp",
     "*.mongodb.js"
   ]

+ 3 - 10
apps/app/package.json

@@ -26,7 +26,7 @@
     "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
     "//// for CI": "",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
-    "lint:typecheck": "vue-tsc --noEmit",
+    "lint:typecheck": "tsgo --noEmit",
     "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
@@ -34,11 +34,9 @@
     "lint": "run-p lint:**",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
-    "test": "run-p test:jest test:vitest:coverage",
-    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
+    "test": "pnpm run test:vitest:coverage",
     "test:vitest": "vitest run",
     "test:vitest:coverage": "COLUMNS=200 vitest run --coverage",
-    "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "//// misc": "",
     "console": "npm run repl",
@@ -274,8 +272,6 @@
     "@headless-tree/react": "^1.5.3",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
-    "@swc-node/jest": "^1.8.1",
-    "@swc/jest": "^0.2.36",
     "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
@@ -283,7 +279,6 @@
     "@types/bunyan": "^1.8.11",
     "@types/express": "^4.17.21",
     "@types/hast": "^3.0.4",
-    "@types/jest": "^29.5.2",
     "@types/js-cookie": "^3.0.6",
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
@@ -316,14 +311,12 @@
     "i18next-hmr": "^3.1.3",
     "i18next-http-backend": "^2.6.2",
     "i18next-localstorage-backend": "^4.2.0",
-    "jest": "^29.5.0",
-    "jest-date-mock": "^1.0.8",
-    "jest-localstorage-mock": "^2.4.14",
     "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "mdast-util-directive": "^3.0.0",
     "mdast-util-find-and-replace": "^3.0.1",
+    "mongodb-connection-string-url": "^7.0.0",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",

+ 3 - 0
apps/app/src/client/components/Admin/Security/LdapAuthTestModal.jsx

@@ -56,6 +56,9 @@ LdapAuthTestModal.propTypes = {
   onClose: PropTypes.func.isRequired,
 };
 
+/**
+ * @type {React.ComponentType<{ isOpen: boolean, onClose: () => void }>}
+ */
 const LdapAuthTestModalWrapper = withUnstatedContainers(LdapAuthTestModal, []);
 
 export default LdapAuthTestModalWrapper;

+ 9 - 5
apps/app/src/client/components/Admin/Users/GrantAdminButton.tsx

@@ -7,11 +7,14 @@ import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-type GrantAdminButtonProps = {
-  adminUsersContainer: AdminUsersContainer;
+type GrantAdminButtonExternalProps = {
   user: IUserHasId;
 };
 
+type GrantAdminButtonProps = GrantAdminButtonExternalProps & {
+  adminUsersContainer: AdminUsersContainer;
+};
+
 const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
@@ -40,8 +43,9 @@ const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 /**
  * Wrapper component for using unstated
  */
-const GrantAdminButtonWrapper: React.ForwardRefExoticComponent<
-  Pick<any, string | number | symbol> & React.RefAttributes<any>
-> = withUnstatedContainers(GrantAdminButton, [AdminUsersContainer]);
+const GrantAdminButtonWrapper = withUnstatedContainers<
+  GrantAdminButtonExternalProps,
+  GrantAdminButtonProps
+>(GrantAdminButton, [AdminUsersContainer]);
 
 export default GrantAdminButtonWrapper;

+ 3 - 0
apps/app/src/client/components/Admin/Users/SendInvitationEmailButton.jsx

@@ -55,6 +55,9 @@ const SendInvitationEmailButton = (props) => {
   );
 };
 
+/**
+ * @type {React.ComponentType<{ user: import('@growi/core').IUserHasId, isInvitationEmailSended: boolean, onSuccessfullySentInvitationEmail: () => void }>}
+ */
 const SendInvitationEmailButtonWrapper = withUnstatedContainers(
   SendInvitationEmailButton,
   [AdminUsersContainer],

+ 1 - 0
apps/app/src/client/components/Admin/Users/StatusActivateButton.jsx

@@ -52,6 +52,7 @@ const StatusActivateFormWrapperFC = (props) => {
 
 /**
  * Wrapper component for using unstated
+ * @type {React.ComponentType<{ user: import('@growi/core').IUserHasId }>}
  */
 const StatusActivateFormWrapper = withUnstatedContainers(
   StatusActivateFormWrapperFC,

+ 9 - 5
apps/app/src/client/components/Admin/Users/UserMenu.tsx

@@ -18,11 +18,14 @@ import UserRemoveButton from './UserRemoveButton';
 
 import styles from './UserMenu.module.scss';
 
-type UserMenuProps = {
-  adminUsersContainer: AdminUsersContainer;
+type UserMenuExternalProps = {
   user: IUserHasId;
 };
 
+type UserMenuProps = UserMenuExternalProps & {
+  adminUsersContainer: AdminUsersContainer;
+};
+
 const UserMenu = (props: UserMenuProps) => {
   const { t } = useTranslation('admin');
 
@@ -146,8 +149,9 @@ const UserMenu = (props: UserMenuProps) => {
 /**
  * Wrapper component for using unstated
  */
-const UserMenuWrapper: React.ForwardRefExoticComponent<
-  Pick<any, string | number | symbol> & React.RefAttributes<any>
-> = withUnstatedContainers(UserMenu, [AdminUsersContainer]);
+const UserMenuWrapper = withUnstatedContainers<
+  UserMenuExternalProps,
+  UserMenuProps
+>(UserMenu, [AdminUsersContainer]);
 
 export default UserMenuWrapper;

+ 1 - 0
apps/app/src/client/components/Admin/Users/UserRemoveButton.jsx

@@ -60,6 +60,7 @@ const UserRemoveButtonWrapperFC = (props) => {
 
 /**
  * Wrapper component for using unstated
+ * @type {React.ComponentType<{ user: import('@growi/core').IUserHasId }>}
  */
 const UserRemoveButtonWrapper = withUnstatedContainers(
   UserRemoveButtonWrapperFC,

+ 7 - 4
apps/app/src/client/components/Admin/Users/UserTable.tsx

@@ -10,7 +10,9 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { SortIcons } from './SortIcons';
 import UserMenu from './UserMenu';
 
-type UserTableProps = {
+type UserTableExternalProps = Record<string, never>;
+
+type UserTableProps = UserTableExternalProps & {
   adminUsersContainer: AdminUsersContainer;
 };
 
@@ -189,8 +191,9 @@ const UserTable = (props: UserTableProps) => {
   );
 };
 
-const UserTableWrapper = withUnstatedContainers(UserTable, [
-  AdminUsersContainer,
-]);
+const UserTableWrapper = withUnstatedContainers<
+  UserTableExternalProps,
+  UserTableProps
+>(UserTable, [AdminUsersContainer]);
 
 export default UserTableWrapper;

+ 3 - 1
apps/app/src/client/components/InstallerForm.tsx

@@ -5,7 +5,7 @@ import { AllLang, Lang } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
-import { i18n as i18nConfig } from '^/config/next-i18next.config';
+import * as nextI18nConfig from '^/config/next-i18next.config';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useTWithOpt } from '~/client/util/t-with-opt';
@@ -16,6 +16,8 @@ import styles from './InstallerForm.module.scss';
 
 const moduleClass = styles['installer-form'] ?? '';
 
+const i18nConfig = nextI18nConfig.i18n;
+
 type Props = {
   minPasswordLength: number;
 };

+ 2 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -9,6 +9,8 @@ import styles from './AccessTokenScopeList.module.scss';
 
 const moduleClass = styles['access-token-scope-list'] ?? '';
 
+// biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+// @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
 interface scopeObject {
   [key: string]: Scope | scopeObject;
 }

+ 4 - 2
apps/app/src/client/components/Me/BasicInfoSettings.tsx

@@ -1,9 +1,9 @@
-import React, { type JSX, useEffect, useState } from 'react';
+import { type JSX, useEffect, useState } from 'react';
 import type { IUser } from '@growi/core/dist/interfaces';
 import { useAtomValue } from 'jotai';
 import { i18n, useTranslation } from 'next-i18next';
 
-import { i18n as i18nConfig } from '^/config/next-i18next.config';
+import * as nextI18nConfig from '^/config/next-i18next.config';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { registrationWhitelistAtom } from '~/states/server-configurations';
@@ -12,6 +12,8 @@ import {
   useUpdateBasicInfo,
 } from '~/stores/personal-settings';
 
+const i18nConfig = nextI18nConfig.i18n;
+
 export const BasicInfoSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const registrationWhitelist = useAtomValue(registrationWhitelistAtom);

+ 1 - 1
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -191,7 +191,7 @@ const PageOperationMenuItems = (
             <DropdownItem
               onClick={openPageBulkExportSelectModal}
               className="grw-page-control-dropdown-item"
-              disabled={!isUploadEnabled ?? true}
+              disabled={!isUploadEnabled}
             >
               <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
                 cloud_download

+ 28 - 14
apps/app/src/client/components/UnstatedUtils.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { Subscribe } from 'unstated';
+import { type Container, Subscribe } from 'unstated';
 
 /**
  * generate K/V object by specified instances
@@ -41,21 +41,35 @@ function generateAutoNamedProps(instances) {
  *    )}
  *  </Subscribe>
  */
-export function withUnstatedContainers<T, P>(
-  Component,
-  containerClasses,
+export function withUnstatedContainers<
+  ExternalProps extends Record<string, unknown>,
+  InternalProps extends ExternalProps = ExternalProps,
+>(
+  Component: React.ComponentType<InternalProps>,
+  containerClasses: (typeof Container)[],
 ): React.ForwardRefExoticComponent<
-  React.PropsWithoutRef<P> & React.RefAttributes<T>
+  React.PropsWithoutRef<ExternalProps> & React.RefAttributes<unknown>
 > {
-  const unstatedContainer = React.forwardRef<T, P>((props, ref) => (
-    // wrap with <Subscribe></Subscribe>
-    <Subscribe to={containerClasses}>
-      {(...containers) => {
-        const propsForContainers = generateAutoNamedProps(containers);
-        return <Component {...props} {...propsForContainers} ref={ref} />;
-      }}
-    </Subscribe>
-  ));
+  const unstatedContainer = React.forwardRef<unknown, ExternalProps>(
+    (props, ref) => (
+      // wrap with <Subscribe></Subscribe>
+      <Subscribe to={containerClasses}>
+        {(...containers) => {
+          // Container props are dynamically generated based on class names
+          const propsForContainers = generateAutoNamedProps(containers) as Omit<
+            InternalProps,
+            keyof ExternalProps
+          >;
+          const mergedProps = {
+            ...props,
+            ...propsForContainers,
+            ref,
+          } as InternalProps & { ref: typeof ref };
+          return <Component {...mergedProps} />;
+        }}
+      </Subscribe>
+    ),
+  );
   unstatedContainer.displayName = 'unstatedContainer';
   return unstatedContainer;
 }

+ 4 - 1
apps/app/src/client/util/scope-util.ts

@@ -17,8 +17,9 @@ function parseSubScope(
 
   for (const action of actions) {
     if (typeof subObjForActions[action] === 'string') {
+      // Safe: parseScopes only accepts SCOPE constant which contains valid Scope strings
       result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] =
-        subObjForActions[action];
+        subObjForActions[action] as Scope;
       subObjForActions[action] = undefined;
     }
   }
@@ -38,6 +39,7 @@ function parseSubScope(
       for (const action of actions) {
         const val = subObjForActions[action]?.[ck];
         if (typeof val === 'string') {
+          // Safe: parseScopes only accepts SCOPE constant which contains valid Scope strings
           result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] =
             val as Scope;
         }
@@ -87,6 +89,7 @@ export function parseScopes({
       for (const action of actions) {
         const val = scopes[action]?.[key];
         if (typeof val === 'string') {
+          // Safe: parseScopes only accepts SCOPE constant which contains valid Scope strings
           allObj[`${action.toLowerCase()}:all`] = val as Scope;
         }
       }

+ 1 - 1
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantAlert.tsx

@@ -61,7 +61,7 @@ export const FixPageGrantAlert = (): JSX.Element => {
   const currentUser = useCurrentUser();
   const pageData = useCurrentPageData();
 
-  const hasParent = pageData?.parent != null ?? false;
+  const hasParent = pageData?.parent != null;
   const pageId = pageData?._id;
 
   const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(

+ 4 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -6,6 +6,8 @@ import type { IExternalUserGroupRelationHasId } from '~/features/external-user-g
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
@@ -14,14 +16,11 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation');
 
 const express = require('express');
 const { query } = require('express-validator');
-
 const router = express.Router();
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   const validators = {
     list: [

+ 5 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -12,7 +12,9 @@ import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
@@ -43,13 +45,11 @@ interface AuthorizedRequest extends Request {
  *            type: number
  */
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
   const addActivity = generateAddActivityMiddleware();
 
-  const activityEvent = crowi.event('activity');
+  const activityEvent = crowi.events.activity;
 
   const isExecutingSync = () => {
     return (

+ 112 - 38
apps/app/test/integration/service/external-user-group-sync.test.ts → apps/app/src/features/external-user-group/server/service/external-user-group-sync.integ.ts

@@ -1,21 +1,35 @@
-import type { IUserHasId } from '@growi/core';
+import type { IPage, IUserHasId } from '@growi/core';
 import mongoose, { Types } from 'mongoose';
-
 import {
-  ExternalGroupProviderType,
-  type ExternalUserGroupTreeNode,
-  type IExternalUserGroup,
-  type IExternalUserGroupHasId,
-} from '../../../src/features/external-user-group/interfaces/external-user-group';
-import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
-import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
-import ExternalUserGroupSyncService from '../../../src/features/external-user-group/server/service/external-user-group-sync';
-import type Crowi from '../../../src/server/crowi';
-import ExternalAccount from '../../../src/server/models/external-account';
-import { configManager } from '../../../src/server/service/config-manager';
-import instanciateExternalAccountService from '../../../src/server/service/external-account';
-import PassportService from '../../../src/server/service/passport';
-import { getInstance } from '../setup-crowi';
+  afterEach,
+  beforeAll,
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import ExternalAccount from '~/server/models/external-account';
+import type { PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+import instanciateExternalAccountService from '~/server/service/external-account';
+import type PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+
+import type {
+  ExternalUserGroupTreeNode,
+  IExternalUserGroup,
+  IExternalUserGroupHasId,
+} from '../../interfaces/external-user-group';
+import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
+import ExternalUserGroup from '../models/external-user-group';
+import ExternalUserGroupRelation from '../models/external-user-group-relation';
+import ExternalUserGroupSyncService from './external-user-group-sync';
 
 // dummy class to implement generateExternalUserGroupTrees which returns test data
 class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
@@ -41,7 +55,6 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
     };
     const parentNode: ExternalUserGroupTreeNode = {
       id: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-      // name is undefined
       userInfos: [
         {
           id: 'parentGroupUser',
@@ -55,7 +68,6 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
     };
     const grandParentNode: ExternalUserGroupTreeNode = {
       id: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-      // email is undefined
       userInfos: [
         {
           id: 'grandParentGroupUser',
@@ -87,8 +99,6 @@ class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
   }
 }
 
-const testService = new TestExternalUserGroupSyncService(null, null);
-
 const checkGroup = (
   group: IExternalUserGroupHasId,
   expected: Omit<IExternalUserGroup, 'createdAt'>,
@@ -107,7 +117,8 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
   const grandParentGroup = await ExternalUserGroup.findOne({
     name: 'grandParentGroup',
   });
-  checkGroup(grandParentGroup, {
+  expect(grandParentGroup).not.toBeNull();
+  checkGroup(grandParentGroup!, {
     externalId: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
     name: 'grandParentGroup',
     description: 'this is a grand parent group',
@@ -116,27 +127,30 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
   });
 
   const parentGroup = await ExternalUserGroup.findOne({ name: 'parentGroup' });
-  checkGroup(parentGroup, {
+  expect(parentGroup).not.toBeNull();
+  checkGroup(parentGroup!, {
     externalId: 'cn=parentGroup,ou=groups,dc=example,dc=org',
     name: 'parentGroup',
     description: 'this is a parent group',
     provider: 'ldap',
-    parent: grandParentGroup._id,
+    parent: grandParentGroup!._id,
   });
 
   const childGroup = await ExternalUserGroup.findOne({ name: 'childGroup' });
-  checkGroup(childGroup, {
+  expect(childGroup).not.toBeNull();
+  checkGroup(childGroup!, {
     externalId: 'cn=childGroup,ou=groups,dc=example,dc=org',
     name: 'childGroup',
     description: 'this is a child group',
     provider: 'ldap',
-    parent: parentGroup._id,
+    parent: parentGroup!._id,
   });
 
   const previouslySyncedGroup = await ExternalUserGroup.findOne({
     name: 'previouslySyncedGroup',
   });
-  checkGroup(previouslySyncedGroup, {
+  expect(previouslySyncedGroup).not.toBeNull();
+  checkGroup(previouslySyncedGroup!, {
     externalId: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
     name: 'previouslySyncedGroup',
     description: 'this is a previouslySynced group',
@@ -145,16 +159,16 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
   });
 
   const grandParentGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: grandParentGroup._id,
+    relatedGroup: grandParentGroup!._id,
   });
   const parentGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: parentGroup._id,
+    relatedGroup: parentGroup!._id,
   });
   const childGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: childGroup._id,
+    relatedGroup: childGroup!._id,
   });
   const previouslySyncedGroupRelations = await ExternalUserGroupRelation.find({
-    relatedGroup: previouslySyncedGroup._id,
+    relatedGroup: previouslySyncedGroup!._id,
   });
 
   if (autoGenerateUserOnGroupSync) {
@@ -205,7 +219,7 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
       'previouslySyncedGroupUser',
     );
 
-    const userPages = await mongoose.model('Page').find({
+    const userPages = await mongoose.model<IPage>('Page').find({
       path: {
         $in: [
           '/user/childGroupUser',
@@ -225,16 +239,76 @@ const checkSync = async (autoGenerateUserOnGroupSync = true) => {
 };
 
 describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
-  let crowi: Crowi;
+  let testService: TestExternalUserGroupSyncService;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let Page: PageModel;
+  let rootPageId: Types.ObjectId;
+  let userPageId: Types.ObjectId;
 
   beforeAll(async () => {
-    crowi = await getInstance();
+    // Initialize configManager
+    const s2sMessagingServiceMock = mock<S2sMessagingService>();
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+    await configManager.loadConfigs();
+
+    const crowi: Crowi = await getInstance();
+
+    // Initialize models with crowi mock
+    const pageModule = await import('~/server/models/page');
+    Page = pageModule.default(crowi);
+
+    const userModule = await import('~/server/models/user/index');
+    userModule.default(crowi);
+
+    // Initialize services with mocked PassportService
     await configManager.updateConfig('app:isV5Compatible', true);
-    const passportService = new PassportService(crowi);
-    instanciateExternalAccountService(passportService);
+
+    // Create PassportService mock with required methods for externalAccountService
+    const passportServiceMock = mock<PassportService>({
+      isSameUsernameTreatedAsIdenticalUser: vi.fn().mockReturnValue(false),
+      isSameEmailTreatedAsIdenticalUser: vi.fn().mockReturnValue(false),
+    });
+    instanciateExternalAccountService(passportServiceMock);
+
+    // Create root page and /user page for UserEvent.onActivated to work
+    rootPageId = new Types.ObjectId();
+    userPageId = new Types.ObjectId();
+
+    // Check if root page already exists
+    const existingRootPage = await Page.findOne({ path: '/' });
+    if (existingRootPage == null) {
+      await Page.insertMany([
+        {
+          _id: rootPageId,
+          path: '/',
+          grant: Page.GRANT_PUBLIC,
+        },
+      ]);
+    } else {
+      rootPageId = existingRootPage._id;
+    }
+
+    // Check if /user page already exists
+    const existingUserPage = await Page.findOne({ path: '/user' });
+    if (existingUserPage == null) {
+      await Page.insertMany([
+        {
+          _id: userPageId,
+          path: '/user',
+          grant: Page.GRANT_PUBLIC,
+          parent: rootPageId,
+          isEmpty: true,
+        },
+      ]);
+    } else {
+      userPageId = existingUserPage._id;
+    }
   });
 
   beforeEach(async () => {
+    // Create new testService instance for each test to reset syncStatus
+    testService = new TestExternalUserGroupSyncService(null, null);
+
     await ExternalUserGroup.create({
       name: 'nameBeforeEdit',
       description: 'this is a description before edit',
@@ -284,7 +358,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': false,
     };
 
-    beforeAll(async () => {
+    beforeEach(async () => {
       await configManager.updateConfigs(configParams);
     });
 
@@ -300,7 +374,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': true,
     };
 
-    beforeAll(async () => {
+    beforeEach(async () => {
       await configManager.updateConfigs(configParams);
     });
 
@@ -316,7 +390,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
       'external-user-group:ldap:preserveDeletedGroups': false,
     };
 
-    beforeAll(async () => {
+    beforeEach(async () => {
       await configManager.updateConfigs(configParams);
 
       const groupId = new Types.ObjectId();

+ 105 - 107
apps/app/test/integration/service/ldap-user-group-sync.test.ts → apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.integ.ts

@@ -1,11 +1,16 @@
 import ldap, { type Client } from 'ldapjs';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
 
-import { LdapUserGroupSyncService } from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
-import type Crowi from '../../../src/server/crowi';
-import { configManager } from '../../../src/server/service/config-manager';
-import { ldapService } from '../../../src/server/service/ldap';
-import PassportService from '../../../src/server/service/passport';
-import { getInstance } from '../setup-crowi';
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import { ldapService } from '~/server/service/ldap';
+import PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+
+import { LdapUserGroupSyncService } from './ldap-user-group-sync';
 
 describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
   let crowi: Crowi;
@@ -23,10 +28,10 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
       'ldap://openldap:1389/dc=example,dc=org',
   };
 
-  jest.mock('../../../src/server/service/ldap');
-  const mockBind = jest.spyOn(ldapService, 'bind');
-  const mockLdapSearch = jest.spyOn(ldapService, 'search');
-  const mockLdapCreateClient = jest.spyOn(ldap, 'createClient');
+  const mockBind = vi.spyOn(ldapService, 'bind');
+  const mockSearchGroupDir = vi.spyOn(ldapService, 'searchGroupDir');
+  const mockSearch = vi.spyOn(ldapService, 'search');
+  const mockLdapCreateClient = vi.spyOn(ldap, 'createClient');
 
   beforeAll(async () => {
     crowi = await getInstance();
@@ -42,77 +47,79 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
     const passportService = new PassportService(crowi);
     ldapUserGroupSyncService = new LdapUserGroupSyncService(
       passportService,
-      null,
+      mock<S2sMessagingService>(),
       null,
     );
   });
 
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
   describe('When there is no circular reference in group tree', () => {
     it('creates ExternalUserGroupTrees', async () => {
-      // mock search on LDAP server
-      mockLdapSearch.mockImplementation((filter, base) => {
-        if (base === 'ou=groups,dc=example,dc=org') {
-          // search groups
-          return Promise.resolve([
+      // mock searchGroupDir for group entries
+      mockSearchGroupDir.mockResolvedValue([
+        {
+          objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['childGroup'] },
+            { type: 'description', values: ['this is a child group'] },
             {
-              objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['childGroup'] },
-                { type: 'description', values: ['this is a child group'] },
-                {
-                  type: 'member',
-                  values: ['cn=childGroupUser,ou=users,dc=example,dc=org'],
-                },
-              ],
+              type: 'member',
+              values: ['cn=childGroupUser,ou=users,dc=example,dc=org'],
             },
+          ],
+        },
+        {
+          objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['parentGroup'] },
+            { type: 'description', values: ['this is a parent group'] },
             {
-              objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['parentGroup'] },
-                { type: 'description', values: ['this is a parent group'] },
-                {
-                  type: 'member',
-                  values: [
-                    'cn=childGroup,ou=groups,dc=example,dc=org',
-                    'cn=parentGroupUser,ou=users,dc=example,dc=org',
-                  ],
-                },
+              type: 'member',
+              values: [
+                'cn=childGroup,ou=groups,dc=example,dc=org',
+                'cn=parentGroupUser,ou=users,dc=example,dc=org',
               ],
             },
-            // root node
+          ],
+        },
+        // root node
+        {
+          objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['grandParentGroup'] },
             {
-              objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['grandParentGroup'] },
-                {
-                  type: 'description',
-                  values: ['this is a grand parent group'],
-                },
-                {
-                  type: 'member',
-                  values: [
-                    'cn=parentGroup,ou=groups,dc=example,dc=org',
-                    'cn=grandParentGroupUser,ou=users,dc=example,dc=org',
-                  ],
-                },
-              ],
+              type: 'description',
+              values: ['this is a grand parent group'],
             },
-            // another root node
             {
-              objectName: 'cn=rootGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['rootGroup'] },
-                { type: 'description', values: ['this is a root group'] },
-                {
-                  type: 'member',
-                  values: ['cn=rootGroupUser,ou=users,dc=example,dc=org'],
-                },
+              type: 'member',
+              values: [
+                'cn=parentGroup,ou=groups,dc=example,dc=org',
+                'cn=grandParentGroupUser,ou=users,dc=example,dc=org',
               ],
             },
-          ]);
-        }
+          ],
+        },
+        // another root node
+        {
+          objectName: 'cn=rootGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['rootGroup'] },
+            { type: 'description', values: ['this is a root group'] },
+            {
+              type: 'member',
+              values: ['cn=rootGroupUser,ou=users,dc=example,dc=org'],
+            },
+          ],
+        },
+      ]);
+
+      // mock search for user lookups
+      mockSearch.mockImplementation((_filter, base) => {
         if (base === 'cn=childGroupUser,ou=users,dc=example,dc=org') {
-          // search childGroupUser
           return Promise.resolve([
             {
               objectName: 'cn=childGroupUser,ou=users,dc=example,dc=org',
@@ -124,7 +131,6 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
             },
           ]);
         }
-        // search parentGroupUser
         if (base === 'cn=parentGroupUser,ou=users,dc=example,dc=org') {
           return Promise.resolve([
             {
@@ -137,7 +143,6 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
             },
           ]);
         }
-        // search grandParentGroupUser
         if (base === 'cn=grandParentGroupUser,ou=users,dc=example,dc=org') {
           return Promise.resolve([
             {
@@ -150,7 +155,6 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
             },
           ]);
         }
-        // search rootGroupUser
         if (base === 'cn=rootGroupUser,ou=users,dc=example,dc=org') {
           return Promise.resolve([
             {
@@ -243,52 +247,46 @@ describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
 
   describe('When there is a circular reference in group tree', () => {
     it('rejects creating ExternalUserGroupTrees', async () => {
-      // mock search on LDAP server
-      mockLdapSearch.mockImplementation((filter, base) => {
-        if (base === 'ou=groups,dc=example,dc=org') {
-          // search groups
-          return Promise.resolve([
-            // childGroup and parentGroup have circular reference
+      // mock searchGroupDir for group entries with circular reference
+      mockSearchGroupDir.mockResolvedValue([
+        // childGroup and parentGroup have circular reference
+        {
+          objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['childGroup'] },
+            { type: 'description', values: ['this is a child group'] },
             {
-              objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['childGroup'] },
-                { type: 'description', values: ['this is a child group'] },
-                {
-                  type: 'member',
-                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
-                },
-              ],
+              type: 'member',
+              values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
             },
+          ],
+        },
+        {
+          objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['parentGroup'] },
+            { type: 'description', values: ['this is a parent group'] },
             {
-              objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['parentGroup'] },
-                { type: 'description', values: ['this is a parent group'] },
-                {
-                  type: 'member',
-                  values: ['cn=childGroup,ou=groups,dc=example,dc=org'],
-                },
-              ],
+              type: 'member',
+              values: ['cn=childGroup,ou=groups,dc=example,dc=org'],
             },
+          ],
+        },
+        {
+          objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+          attributes: [
+            { type: 'cn', values: ['grandParentGroup'] },
             {
-              objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
-              attributes: [
-                { type: 'cn', values: ['grandParentGroup'] },
-                {
-                  type: 'description',
-                  values: ['this is a grand parent group'],
-                },
-                {
-                  type: 'member',
-                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
-                },
-              ],
+              type: 'description',
+              values: ['this is a grand parent group'],
             },
-          ]);
-        }
-        return Promise.reject(new Error('not found'));
-      });
+            {
+              type: 'member',
+              values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
+            },
+          ],
+        },
+      ]);
 
       await expect(
         ldapUserGroupSyncService?.generateExternalUserGroupTrees(),

+ 6 - 4
apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -6,6 +6,8 @@ import mongoose from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
 import { GrowiPlugin } from '../../../models';
@@ -28,15 +30,15 @@ const validator = {
 };
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+  const adminRequired = adminRequiredFactory(crowi);
 
   const router = express.Router();
 
   router.get(
     '/',
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]),
     loginRequiredStrictly,
     adminRequired,

+ 16 - 12
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -1,3 +1,4 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -6,6 +7,7 @@ import type { Request, RequestHandler } from 'express';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -16,18 +18,14 @@ import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-va
 
 const logger = loggerFactory('growi:routes:apiv3:openai:create-ai-assistant');
 
-type CreateAssistantFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqBody = UpsertAiAssistantData;
 
-type Req = Request<undefined, Response, ReqBody> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, ReqBody> & {
+  user?: IUserHasId;
 };
 
-export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const createAiAssistantFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   return [
     accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT], {
@@ -35,20 +33,26 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    upsertAiAssistantValidator,
+    ...upsertAiAssistantValidator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
       }
 
       try {
-        const aiAssistantData = { ...req.body, owner: req.user._id };
+        const aiAssistantData = { ...req.body, owner: user._id };
 
         const isLearnablePageLimitExceeded =
           await openaiService.isLearnablePageLimitExceeded(
-            req.user,
+            user,
             aiAssistantData.pagePathPatterns,
           );
         if (isLearnablePageLimitExceeded) {
@@ -60,7 +64,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
 
         const aiAssistant = await openaiService.createAiAssistant(
           req.body,
-          req.user,
+          user,
         );
 
         return res.apiv3({ aiAssistant });

+ 13 - 9
apps/app/src/features/openai/server/routes/ai-assistants.ts

@@ -1,3 +1,4 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -5,6 +6,7 @@ import type { Request, RequestHandler } from 'express';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -13,16 +15,12 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-ai-assistants');
 
-type GetAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
-
-type Req = Request<undefined, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getAiAssistantsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   return [
     accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
@@ -31,6 +29,12 @@ export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
     loginRequiredStrictly,
     certifyAiService,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -38,7 +42,7 @@ export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
 
       try {
         const accessibleAiAssistants =
-          await openaiService.getAccessibleAiAssistants(req.user);
+          await openaiService.getAccessibleAiAssistants(user);
 
         return res.apiv3({ accessibleAiAssistants });
       } catch (err) {

+ 13 - 11
apps/app/src/features/openai/server/routes/delete-ai-assistant.ts

@@ -1,13 +1,15 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -16,22 +18,18 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:delete-ai-assistants');
 
-type DeleteAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   id: string;
 };
 
-type Req = Request<ReqParams, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<ReqParams, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const deleteAiAssistantsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('id').isMongoId().withMessage('aiAssistant id is required'),
   ];
 
@@ -41,11 +39,15 @@ export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { id } = req.params;
       const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
       try {
         const deletedAiAssistant = await deleteAiAssistant(user._id, id);

+ 13 - 11
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -1,13 +1,15 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -17,20 +19,16 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:delete-thread');
 
-type DeleteThreadFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = IApiv3DeleteThreadParams;
 
-type Req = Request<ReqParams, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<ReqParams, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const deleteThreadFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('aiAssistantId').isMongoId().withMessage('threadId is required'),
     param('threadRelationId')
       .isMongoId()
@@ -43,11 +41,15 @@ export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { aiAssistantId, threadRelationId } = req.params;
       const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 19 - 20
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -1,9 +1,9 @@
+import assert from 'node:assert';
 import { getIdStringForRef } from '@growi/core';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import type { Request, RequestHandler, Response } from 'express';
-import type { ValidationChain } from 'express-validator';
+import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 import { zodResponseFormat } from 'openai/helpers/zod';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
@@ -13,6 +13,7 @@ import { z } from 'zod';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -52,16 +53,10 @@ const LlmEditorAssistantResponseSchema = z
   })
   .describe('The response format for the editor assistant');
 
-type Req = Request<undefined, Response, EditRequestBody> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, EditRequestBody> & {
+  user?: IUserHasId;
 };
 
-// -----------------------------------------------------------------------------
-// Endpoint handler factory
-// -----------------------------------------------------------------------------
-
-type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
 // -----------------------------------------------------------------------------
 // Instructions
 // -----------------------------------------------------------------------------
@@ -173,15 +168,13 @@ ${
 /**
  * Create endpoint handlers for editor assistant
  */
-export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
-  crowi,
-) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const postMessageToEditHandlersFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   // Validator setup
-  const validator: ValidationChain[] = [
+  const validator = [
     body('userMessage')
       .isString()
       .withMessage('userMessage must be string')
@@ -214,9 +207,15 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const {
         userMessage,
         pageBody,
@@ -261,7 +260,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
       if (aiAssistantId != null) {
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
           aiAssistantId,
-          req.user,
+          user,
         );
         if (!isAiAssistantUsable) {
           return res.apiv3Err(
@@ -339,7 +338,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (
 
           // Process annotations
           if (content?.type === 'text' && content?.text?.annotations != null) {
-            await replaceAnnotationWithPageLink(content, req.user.lang);
+            await replaceAnnotationWithPageLink(content, user.lang);
           }
 
           // Process text

+ 23 - 12
apps/app/src/features/openai/server/routes/get-recent-threads.ts

@@ -1,12 +1,14 @@
+import assert from 'node:assert';
 import { type IUserHasId, SCOPE } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { query, type ValidationChain } from 'express-validator';
+import { query } from 'express-validator';
 import type { PaginateResult } from 'mongoose';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -18,23 +20,24 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-recent-threads');
 
-type GetRecentThreadsFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqQuery = {
   page?: number;
   limit?: number;
 };
 
-type Req = Request<undefined, Response, undefined, ReqQuery> & {
-  user: IUserHasId;
+type Req = Request<
+  Record<string, string>,
+  ApiV3Response,
+  undefined,
+  ReqQuery
+> & {
+  user?: IUserHasId;
 };
 
-export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getRecentThreadsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     query('page')
       .optional()
       .isInt()
@@ -48,14 +51,22 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
   ];
 
   return [
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -65,7 +76,7 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
         const paginateResult: PaginateResult<ThreadRelationDocument> =
           await ThreadRelationModel.paginate(
             {
-              userId: req.user._id,
+              userId: user._id,
               type: ThreadType.KNOWLEDGE,
               isActive: true,
             },

+ 22 - 16
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -1,12 +1,14 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -15,36 +17,36 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-threads');
 
-type GetThreadsFactory = (crowi: Crowi) => RequestHandler[];
-
-type ReqParams = {
-  aiAssistantId: string;
-};
-
-type Req = Request<ReqParams, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const getThreadsFactory: GetThreadsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getThreadsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('aiAssistantId')
       .isMongoId()
       .withMessage('aiAssistantId must be string'),
   ];
 
   return [
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -52,10 +54,14 @@ export const getThreadsFactory: GetThreadsFactory = (crowi) => {
 
       try {
         const { aiAssistantId } = req.params;
+        assert(
+          aiAssistantId != null,
+          'aiAssistantId is required (validated by express-validator)',
+        );
 
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
           aiAssistantId,
-          req.user,
+          user,
         );
         if (!isAiAssistantUsable) {
           return res.apiv3Err(

+ 24 - 15
apps/app/src/features/openai/server/routes/message/get-messages.ts

@@ -1,12 +1,14 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -15,26 +17,22 @@ import { certifyAiService } from '../middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-message');
 
-type GetMessagesFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParam = {
-  threadId: string;
-  aiAssistantId: string;
+  threadId?: string;
+  aiAssistantId?: string;
   before?: string;
   after?: string;
   limit?: number;
 };
 
-type Req = Request<ReqParam, Response, undefined> & {
-  user: IUserHasId;
+type Req = Request<ReqParam, ApiV3Response, undefined> & {
+  user?: IUserHasId;
 };
 
-export const getMessagesFactory: GetMessagesFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const getMessagesFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('threadId').isString().withMessage('threadId must be string'),
     param('aiAssistantId')
       .isMongoId()
@@ -50,9 +48,15 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -61,9 +65,14 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
       try {
         const { threadId, aiAssistantId, limit, before, after } = req.params;
 
+        assert(
+          threadId != null && aiAssistantId != null,
+          'threadId and aiAssistantId are required (validated by express-validator)',
+        );
+
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
           aiAssistantId,
-          req.user,
+          user,
         );
         if (!isAiAssistantUsable) {
           return res.apiv3Err(
@@ -74,7 +83,7 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
 
         const messages = await openaiService.getMessageData(
           threadId,
-          req.user.lang,
+          user.lang,
           {
             limit,
             before,

+ 19 - 16
apps/app/src/features/openai/server/routes/message/post-message.ts

@@ -1,8 +1,8 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import type { Request, RequestHandler, Response } from 'express';
-import type { ValidationChain } from 'express-validator';
+import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 import type { AssistantStream } from 'openai/lib/AssistantStream';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
@@ -12,6 +12,7 @@ import { getOrCreateChatAssistant } from '~/features/openai/server/services/assi
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -55,20 +56,14 @@ type ReqBody = {
   extendedThinkingMode?: boolean;
 };
 
-type Req = Request<undefined, Response, ReqBody> & {
-  user: IUserHasId;
+type Req = Request<Record<string, string>, ApiV3Response, ReqBody> & {
+  user?: IUserHasId;
 };
 
-type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+export const postMessageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-export const postMessageHandlersFactory: PostMessageHandlersFactory = (
-  crowi,
-) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-
-  const validator: ValidationChain[] = [
+  const validator = [
     body('userMessage')
       .isString()
       .withMessage('userMessage must be string')
@@ -84,14 +79,22 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (
   ];
 
   return [
+    // biome-ignore lint/suspicious/noTsIgnore: Suppress auto fix by lefthook
+    // @ts-ignore - Scope type causes "Type instantiation is excessively deep" with tsgo
     accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const { aiAssistantId, threadId } = req.body;
 
       if (threadId == null) {
@@ -111,7 +114,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (
 
       const isAiAssistantUsable = await openaiService.isAiAssistantUsable(
         aiAssistantId,
-        req.user,
+        user,
       );
       if (!isAiAssistantUsable) {
         return res.apiv3Err(
@@ -188,7 +191,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (
 
         // If annotation is found
         if (content?.type === 'text' && content?.text?.annotations != null) {
-          await replaceAnnotationWithPageLink(content, req.user.lang);
+          await replaceAnnotationWithPageLink(content, user.lang);
         }
 
         res.write(`data: ${JSON.stringify(delta)}\n\n`);

+ 4 - 3
apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts

@@ -1,5 +1,6 @@
-import type { NextFunction, Request, Response } from 'express';
+import type { NextFunction, Request } from 'express';
 
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -8,8 +9,8 @@ import { OpenaiServiceTypes } from '../../../interfaces/ai';
 const logger = loggerFactory('growi:middlewares:certify-ai-service');
 
 export const certifyAiService = (
-  req: Request,
-  res: Response & { apiv3Err },
+  _req: Request,
+  res: ApiV3Response,
   next: NextFunction,
 ): void => {
   const aiEnabled = configManager.getConfig('app:aiEnabled');

+ 11 - 13
apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts

@@ -1,12 +1,14 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { body, param, type ValidationChain } from 'express-validator';
+import { body, param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -18,8 +20,6 @@ const logger = loggerFactory(
   'growi:routes:apiv3:openai:set-default-ai-assistants',
 );
 
-type setDefaultAiAssistantFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   id: string;
 };
@@ -28,17 +28,15 @@ type ReqBody = {
   isDefault: boolean;
 };
 
-type Req = Request<ReqParams, Response, ReqBody>;
+type Req = Request<ReqParams, ApiV3Response, ReqBody>;
 
-export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (
-  crowi,
-) => {
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const setDefaultAiAssistantFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const adminRequired = adminRequiredFactory(crowi);
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('id').isMongoId().withMessage('aiAssistant id is required'),
     body('isDefault').isBoolean().withMessage('isDefault is required'),
   ];
@@ -50,7 +48,7 @@ export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (
     loginRequiredStrictly,
     adminRequired,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();

+ 19 - 12
apps/app/src/features/openai/server/routes/thread.ts

@@ -1,13 +1,14 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -23,18 +24,18 @@ type ReqBody = {
   initialUserMessage?: string;
 };
 
-type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & {
-  user: IUserHasId;
+type CreateThreadReq = Request<
+  Record<string, string>,
+  ApiV3Response,
+  ReqBody
+> & {
+  user?: IUserHasId;
 };
 
-type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
+export const createThreadHandlersFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
-
-  const validator: ValidationChain[] = [
+  const validator = [
     body('type')
       .isIn(Object.values(ThreadType))
       .withMessage('type must be one of "editor" or "knowledge"'),
@@ -54,9 +55,15 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: CreateThreadReq, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
@@ -68,7 +75,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
 
       try {
         const thread = await openaiService.createThread(
-          req.user._id,
+          user._id,
           type,
           aiAssistantId,
           initialUserMessage,

+ 13 - 11
apps/app/src/features/openai/server/routes/update-ai-assistant.ts

@@ -1,13 +1,15 @@
+import assert from 'node:assert';
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import { param, type ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -18,24 +20,20 @@ import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-va
 
 const logger = loggerFactory('growi:routes:apiv3:openai:update-ai-assistants');
 
-type UpdateAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
-
 type ReqParams = {
   id: string;
 };
 
 type ReqBody = UpsertAiAssistantData;
 
-type Req = Request<ReqParams, Response, ReqBody> & {
-  user: IUserHasId;
+type Req = Request<ReqParams, ApiV3Response, ReqBody> & {
+  user?: IUserHasId;
 };
 
-export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+export const updateAiAssistantsFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
-  const validator: ValidationChain[] = [
+  const validator = [
     param('id').isMongoId().withMessage('aiAssistant id is required'),
     ...upsertAiAssistantValidator,
   ];
@@ -46,11 +44,15 @@ export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
     }),
     loginRequiredStrictly,
     certifyAiService,
-    validator,
+    ...validator,
     apiV3FormValidator,
     async (req: Req, res: ApiV3Response) => {
       const { id } = req.params;
       const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
 
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 5 - 4
apps/app/src/features/openai/server/services/openai.ts

@@ -1,4 +1,7 @@
 import assert from 'node:assert';
+import fs from 'node:fs';
+import { Readable, Transform, Writable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
 import type { IPage, IUser, Lang, Nullable, Ref } from '@growi/core';
 import {
   getIdForRef,
@@ -10,13 +13,11 @@ import {
 import { deepEquals } from '@growi/core/dist/utils';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import fs from 'fs';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
-import { type OpenAI, toFile } from 'openai';
+import type { OpenAI } from 'openai';
+import { toFile } from 'openai';
 import type { ChatCompletionChunk } from 'openai/resources/chat/completions';
-import { Readable, Transform, Writable } from 'stream';
-import { pipeline } from 'stream/promises';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ThreadRelationModel, {

+ 2 - 3
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -5,6 +5,7 @@ import { Router } from 'express';
 import { body, validationResult } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
@@ -23,9 +24,7 @@ interface AuthorizedRequest extends Request {
 
 module.exports = (crowi: Crowi): Router => {
   const accessTokenParser = crowi.accessTokenParser;
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   const validators = {
     pageBulkExport: [

+ 1 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -91,7 +91,7 @@ class PageBulkExportJobCronService
   constructor(crowi: Crowi) {
     super();
     this.crowi = crowi;
-    this.activityEvent = crowi.event('activity');
+    this.activityEvent = crowi.events.activity;
     this.parallelExecLimit = configManager.getConfig(
       'app:pageBulkExportParallelExecLimit',
     );

+ 4 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs';
 import path from 'node:path';
-import { pipeline, Writable } from 'node:stream';
+import { pipeline, Readable, Writable } from 'node:stream';
 import { dynamicImport } from '@cspell/dynamic-import';
 import { isPopulated } from '@growi/core';
 import {
@@ -126,11 +126,13 @@ export async function exportPagesToFsAsync(
           path: { $gt: pageBulkExportJob.lastExportedPagePath },
         }
       : { pageBulkExportJob };
-  const pageSnapshotsReadable = PageBulkExportPageSnapshot.find(findQuery)
+  const pageSnapshotsCursor = PageBulkExportPageSnapshot.find(findQuery)
     .populate('revision')
     .sort({ path: 1 })
     .lean()
     .cursor({ batchSize: this.pageBatchSize });
+  // Wrap Mongoose Cursor with Readable.from() for proper type compatibility
+  const pageSnapshotsReadable = Readable.from(pageSnapshotsCursor);
 
   const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);
 

+ 31 - 11
apps/app/src/features/page-tree/components/ItemsTree.spec.tsx

@@ -1,7 +1,7 @@
 import type React from 'react';
 import type { FC } from 'react';
 import { Suspense } from 'react';
-import { render, waitFor } from '@testing-library/react';
+import { act, render, waitFor } from '@testing-library/react';
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 
@@ -179,7 +179,9 @@ describe('ItemsTree', () => {
       });
 
       // Give time for any additional API calls that might happen
-      await new Promise((resolve) => setTimeout(resolve, 100));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 100));
+      });
 
       // Key assertion: API should only be called for:
       // 1. root-page-id (the only expanded node by default)
@@ -241,7 +243,9 @@ describe('ItemsTree', () => {
         expect(mockApiv3Get).toHaveBeenCalled();
       });
 
-      await new Promise((resolve) => setTimeout(resolve, 100));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 100));
+      });
 
       const childrenApiCalls = mockApiv3Get.mock.calls.filter(
         (call) => call[0] === '/page-listing/children',
@@ -306,7 +310,9 @@ describe('ItemsTree', () => {
       });
 
       // Wait for any potential additional API calls
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 200));
+      });
 
       // Critical assertion: Only root-page-id should have children fetched
       // folder-1 and folder-2 should NOT be fetched even though they are folders (descendantCount > 0)
@@ -385,7 +391,9 @@ describe('ItemsTree', () => {
       );
 
       // Give some extra time for any unwanted calls
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 200));
+      });
 
       // Should fetch: root-page-id (initial), sandbox-id (ancestor of target)
       // Should NOT fetch: other-id (not an ancestor of target)
@@ -469,7 +477,9 @@ describe('ItemsTree', () => {
 
       // Wait a reasonable amount of time to detect infinite loops
       // If there's an infinite loop, we'd see many API calls within this time
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // Force re-render to simulate React re-renders that could trigger the loop
       rerender(
@@ -485,7 +495,9 @@ describe('ItemsTree', () => {
       );
 
       // Wait more time for potential infinite loop to manifest
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // Key assertion: API calls for parent-1 should be bounded
       // An infinite loop would cause this count to be very high (100+)
@@ -555,7 +567,9 @@ describe('ItemsTree', () => {
       await waitFor(() => {
         expect(mockApiv3Get).toHaveBeenCalled();
       });
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 200));
+      });
 
       const callsAfterInitialLoad = totalApiCalls;
 
@@ -564,7 +578,9 @@ describe('ItemsTree', () => {
       // we're mainly testing the initial render with creatingParentId set
 
       // Wait to ensure no more calls happen
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // Verify API calls stabilized
       expect(totalApiCalls).toBeLessThanOrEqual(callsAfterInitialLoad + 2);
@@ -624,7 +640,9 @@ describe('ItemsTree', () => {
       await waitFor(() => {
         expect(mockApiv3Get).toHaveBeenCalled();
       });
-      await new Promise((resolve) => setTimeout(resolve, 300));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 300));
+      });
 
       const callsBeforeReset = apiCallCount;
 
@@ -647,7 +665,9 @@ describe('ItemsTree', () => {
         </TestWrapper>,
       );
 
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // API calls should be bounded even after state changes
       // The difference should be minimal (just the initial load after remount)

+ 2 - 3
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -14,6 +14,7 @@ import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { resolveFromRoot } from '~/server/util/project-dir-utils';
 import loggerFactory from '~/utils/logger';
@@ -31,9 +32,7 @@ const validator = {
 let presetTemplateSummaries: TemplateSummary[];
 
 module.exports = (crowi: Crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
 
   /**
    * @swagger

+ 8 - 3
apps/app/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts → apps/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.integ.ts

@@ -1,12 +1,17 @@
 import type { Collection } from 'mongodb';
-import mongoose from 'mongoose';
-
-import migrate from '~/migrations/20210913153942-migrate-slack-app-integration-schema';
+import * as mongoose from 'mongoose';
 
 describe('migrate-slack-app-integration-schema', () => {
   let collection: Collection;
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  let migrate: any;
 
   beforeAll(async () => {
+    // Use dynamic import for ESM/CJS interop - Vitest handles the mixed syntax
+    const migrateModule = await import(
+      './20210913153942-migrate-slack-app-integration-schema.js'
+    );
+    migrate = migrateModule.default || migrateModule;
     collection = mongoose.connection.collection('slackappintegrations');
 
     await collection.insertMany([

+ 1 - 1
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -53,7 +53,7 @@ function emitPageSeenEvent(
     return;
   }
 
-  const pageEvent = crowi.event('page');
+  const pageEvent = crowi.events.page;
   pageEvent.emit('seen', pageId, user);
 }
 

+ 12 - 8
apps/app/src/pages/_search/use-hydrate-server-configurations.ts

@@ -7,6 +7,7 @@ import {
   rendererConfigAtom,
   showPageLimitationLAtom,
 } from '~/states/server-configurations';
+import { createAtomTuple } from '~/utils/jotai-utils';
 
 import type { ServerConfigurationProps } from './types';
 
@@ -18,15 +19,18 @@ export const useHydrateServerConfigurationAtoms = (
   serverConfig: ServerConfigurationProps['serverConfig'] | undefined,
   rendererConfigs: RendererConfig | undefined,
 ): void => {
-  // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(
+  const tuples =
     serverConfig == null || rendererConfigs == null
       ? []
       : [
-          [isContainerFluidAtom, serverConfig.isContainerFluid],
-          [showPageLimitationLAtom, serverConfig.showPageLimitationL],
-          [rendererConfigAtom, rendererConfigs],
-          [disableUserPagesAtom, serverConfig.disableUserPages],
-        ],
-  );
+          createAtomTuple(isContainerFluidAtom, serverConfig.isContainerFluid),
+          createAtomTuple(
+            showPageLimitationLAtom,
+            serverConfig.showPageLimitationL,
+          ),
+          createAtomTuple(rendererConfigAtom, rendererConfigs),
+          createAtomTuple(disableUserPagesAtom, serverConfig.disableUserPages),
+        ];
+
+  useHydrateAtoms(tuples);
 };

+ 13 - 9
apps/app/src/pages/basic-layout-page/hydrate.ts

@@ -6,6 +6,7 @@ import {
   isSearchServiceReachableAtom,
 } from '~/states/server-configurations';
 import { useHydrateSidebarAtoms } from '~/states/ui/sidebar/hydrate';
+import { createAtomTuple } from '~/utils/jotai-utils';
 
 import type {
   SearchConfigurationProps,
@@ -22,22 +23,25 @@ export const useHydrateBasicLayoutConfigurationAtoms = (
   sidebarConfig: SidebarConfigurationProps['sidebarConfig'] | undefined,
   userUISettings: UserUISettingsProps['userUISettings'] | undefined,
 ): void => {
-  // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(
+  const tuples =
     searchConfig == null
       ? []
       : [
-          [
+          createAtomTuple(
             isSearchServiceConfiguredAtom,
             searchConfig.isSearchServiceConfigured,
-          ],
-          [isSearchServiceReachableAtom, searchConfig.isSearchServiceReachable],
-          [
+          ),
+          createAtomTuple(
+            isSearchServiceReachableAtom,
+            searchConfig.isSearchServiceReachable,
+          ),
+          createAtomTuple(
             isSearchScopeChildrenAsDefaultAtom,
             searchConfig.isSearchScopeChildrenAsDefault,
-          ],
-        ],
-  );
+          ),
+        ];
+
+  useHydrateAtoms(tuples);
 
   useHydrateSidebarAtoms(sidebarConfig, userUISettings);
 };

+ 3 - 2
apps/app/src/pages/common-props/commons.ts

@@ -1,5 +1,6 @@
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { ColorScheme, IUserHasId } from '@growi/core';
+import mongoose from 'mongoose';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -153,12 +154,12 @@ export const getServerSideCommonEachProps = async (
 
   let currentUser: IUserHasId | undefined;
   if (user != null) {
-    const User = crowi.model('User');
+    const User = mongoose.model<IUserHasId>('User');
     const userData = await User.findById(user.id).populate({
       path: 'imageAttachment',
       select: 'filePathProxied',
     });
-    currentUser = userData.toObject();
+    currentUser = userData?.toObject();
   }
 
   // Redirect destination for page transition by next/link

+ 5 - 4
apps/app/src/pages/forgot-password/use-hydrate-server-configurations.ts

@@ -1,6 +1,7 @@
 import { useHydrateAtoms } from 'jotai/utils';
 
 import { isMailerSetupAtom } from '~/states/server-configurations';
+import { createAtomTuple } from '~/utils/jotai-utils';
 
 import type { ServerConfigurationProps } from './types';
 
@@ -11,10 +12,10 @@ import type { ServerConfigurationProps } from './types';
 export const useHydrateServerConfigurationAtoms = (
   serverConfig: ServerConfigurationProps['serverConfig'] | undefined,
 ): void => {
-  // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(
+  const tuples =
     serverConfig == null
       ? []
-      : [[isMailerSetupAtom, serverConfig.isMailerSetup]],
-  );
+      : [createAtomTuple(isMailerSetupAtom, serverConfig.isMailerSetup)];
+
+  useHydrateAtoms(tuples);
 };

+ 59 - 34
apps/app/src/pages/general-page/hydrate.ts

@@ -26,6 +26,7 @@ import {
   rendererConfigAtom,
   showPageSideAuthorsAtom,
 } from '~/states/server-configurations';
+import { createAtomTuple } from '~/utils/jotai-utils';
 
 import type { ServerConfigurationProps } from './types';
 
@@ -37,55 +38,79 @@ export const useHydrateGeneralPageConfigurationAtoms = (
   serverConfig: ServerConfigurationProps['serverConfig'] | undefined,
   rendererConfigs: RendererConfig | undefined,
 ): void => {
-  // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(
+  const tuples =
     serverConfig == null || rendererConfigs == null
       ? []
       : [
-          [aiEnabledAtom, serverConfig.aiEnabled],
-          [
+          createAtomTuple(aiEnabledAtom, serverConfig.aiEnabled),
+          createAtomTuple(
             limitLearnablePageCountPerAssistantAtom,
             serverConfig.limitLearnablePageCountPerAssistant,
-          ],
-          [
+          ),
+          createAtomTuple(
             isUsersHomepageDeletionEnabledAtom,
             serverConfig.isUsersHomepageDeletionEnabled,
-          ],
-          [defaultIndentSizeAtom, serverConfig.adminPreferredIndentSize],
-          [
+          ),
+          createAtomTuple(
+            defaultIndentSizeAtom,
+            serverConfig.adminPreferredIndentSize,
+          ),
+          createAtomTuple(
             elasticsearchMaxBodyLengthToIndexAtom,
             serverConfig.elasticsearchMaxBodyLengthToIndex,
-          ],
-          [
+          ),
+          createAtomTuple(
             isRomUserAllowedToCommentAtom,
             serverConfig.isRomUserAllowedToComment,
-          ],
-          [drawioUriAtom, serverConfig.drawioUri],
-          [isAllReplyShownAtom, serverConfig.isAllReplyShown],
-          [showPageSideAuthorsAtom, serverConfig.showPageSideAuthors],
-          [isContainerFluidAtom, serverConfig.isContainerFluid],
-          [
+          ),
+          createAtomTuple(drawioUriAtom, serverConfig.drawioUri),
+          createAtomTuple(isAllReplyShownAtom, serverConfig.isAllReplyShown),
+          createAtomTuple(
+            showPageSideAuthorsAtom,
+            serverConfig.showPageSideAuthors,
+          ),
+          createAtomTuple(isContainerFluidAtom, serverConfig.isContainerFluid),
+          createAtomTuple(
             isEnabledStaleNotificationAtom,
             serverConfig.isEnabledStaleNotification,
-          ],
-          [disableLinkSharingAtom, serverConfig.disableLinkSharing],
-          [isIndentSizeForcedAtom, serverConfig.isIndentSizeForced],
-          [
+          ),
+          createAtomTuple(
+            disableLinkSharingAtom,
+            serverConfig.disableLinkSharing,
+          ),
+          createAtomTuple(
+            isIndentSizeForcedAtom,
+            serverConfig.isIndentSizeForced,
+          ),
+          createAtomTuple(
             isEnabledAttachTitleHeaderAtom,
             serverConfig.isEnabledAttachTitleHeader,
-          ],
-          [isSlackConfiguredAtom, serverConfig.isSlackConfigured],
-          [isAclEnabledAtom, serverConfig.isAclEnabled],
-          [isUploadAllFileAllowedAtom, serverConfig.isUploadAllFileAllowed],
-          [isUploadEnabledAtom, serverConfig.isUploadEnabled],
-          [isBulkExportPagesEnabledAtom, serverConfig.isBulkExportPagesEnabled],
-          [isPdfBulkExportEnabledAtom, serverConfig.isPdfBulkExportEnabled],
-          [
+          ),
+          createAtomTuple(
+            isSlackConfiguredAtom,
+            serverConfig.isSlackConfigured,
+          ),
+          createAtomTuple(isAclEnabledAtom, serverConfig.isAclEnabled),
+          createAtomTuple(
+            isUploadAllFileAllowedAtom,
+            serverConfig.isUploadAllFileAllowed,
+          ),
+          createAtomTuple(isUploadEnabledAtom, serverConfig.isUploadEnabled),
+          createAtomTuple(
+            isBulkExportPagesEnabledAtom,
+            serverConfig.isBulkExportPagesEnabled,
+          ),
+          createAtomTuple(
+            isPdfBulkExportEnabledAtom,
+            serverConfig.isPdfBulkExportEnabled,
+          ),
+          createAtomTuple(
             isLocalAccountRegistrationEnabledAtom,
             serverConfig.isLocalAccountRegistrationEnabled,
-          ],
-          [rendererConfigAtom, rendererConfigs],
-          [disableUserPagesAtom, serverConfig.disableUserPages],
-        ],
-  );
+          ),
+          createAtomTuple(rendererConfigAtom, rendererConfigs),
+          createAtomTuple(disableUserPagesAtom, serverConfig.disableUserPages),
+        ];
+
+  useHydrateAtoms(tuples);
 };

+ 13 - 6
apps/app/src/pages/me/use-hydrate-server-configurations.ts

@@ -4,6 +4,7 @@ import {
   registrationWhitelistAtom,
   showPageLimitationXLAtom,
 } from '~/states/server-configurations';
+import { createAtomTuple } from '~/utils/jotai-utils';
 
 import type { ServerConfigurationProps } from './types';
 
@@ -14,13 +15,19 @@ import type { ServerConfigurationProps } from './types';
 export const useHydrateServerConfigurationAtoms = (
   serverConfig: ServerConfigurationProps['serverConfig'] | undefined,
 ): void => {
-  // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(
+  const tuples =
     serverConfig == null
       ? []
       : [
-          [showPageLimitationXLAtom, serverConfig.showPageLimitationXL],
-          [registrationWhitelistAtom, serverConfig.registrationWhitelist],
-        ],
-  );
+          createAtomTuple(
+            showPageLimitationXLAtom,
+            serverConfig.showPageLimitationXL,
+          ),
+          createAtomTuple(
+            registrationWhitelistAtom,
+            serverConfig.registrationWhitelist,
+          ),
+        ];
+
+  useHydrateAtoms(tuples);
 };

+ 10 - 4
apps/app/src/pages/trash/use-hydrate-server-configurations.ts

@@ -1,6 +1,7 @@
 import { useHydrateAtoms } from 'jotai/utils';
 
 import { showPageLimitationXLAtom } from '~/states/server-configurations';
+import { createAtomTuple } from '~/utils/jotai-utils';
 
 import type { ServerConfigurationProps } from './types';
 
@@ -11,10 +12,15 @@ import type { ServerConfigurationProps } from './types';
 export const useHydrateServerConfigurationAtoms = (
   serverConfig: ServerConfigurationProps['serverConfig'] | undefined,
 ): void => {
-  // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(
+  const tuples =
     serverConfig == null
       ? []
-      : [[showPageLimitationXLAtom, serverConfig.showPageLimitationXL]],
-  );
+      : [
+          createAtomTuple(
+            showPageLimitationXLAtom,
+            serverConfig.showPageLimitationXL,
+          ),
+        ];
+
+  useHydrateAtoms(tuples);
 };

+ 0 - 822
apps/app/src/server/crowi/index.js

@@ -1,822 +0,0 @@
-import next from 'next';
-import { createTerminus } from '@godaddy/terminus';
-import attachmentRoutes from '@growi/remark-attachment-refs/dist/server';
-import lsxRoutes from '@growi/remark-lsx/dist/server/index.cjs';
-import http from 'http';
-import mongoose from 'mongoose';
-import path from 'path';
-
-import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
-import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
-import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
-import { initializeOpenaiService } from '~/features/openai/server/services/openai';
-import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
-import instanciatePageBulkExportJobCleanUpCronService, {
-  pageBulkExportJobCleanUpCronService,
-} from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
-import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
-import { startCron as startAccessTokenCron } from '~/server/service/access-token';
-import { projectRoot } from '~/server/util/project-dir-utils';
-import { getGrowiVersion } from '~/utils/growi-version';
-import loggerFactory from '~/utils/logger';
-
-import UserEvent from '../events/user';
-import { accessTokenParser } from '../middlewares/access-token-parser';
-import { aclService as aclServiceSingletonInstance } from '../service/acl';
-import AppService from '../service/app';
-import { AttachmentService } from '../service/attachment';
-import { configManager as configManagerSingletonInstance } from '../service/config-manager';
-import instanciateExportService from '../service/export';
-import instanciateExternalAccountService from '../service/external-account';
-import { FileUploader, getUploader } from '../service/file-uploader';
-import {
-  G2GTransferPusherService,
-  G2GTransferReceiverService,
-} from '../service/g2g-transfer';
-import { GrowiBridgeService } from '../service/growi-bridge';
-import { initializeImportService } from '../service/import';
-import { InstallerService } from '../service/installer';
-import { normalizeData } from '../service/normalize-data';
-import PageService from '../service/page';
-import PageGrantService from '../service/page-grant';
-import instanciatePageOperationService from '../service/page-operation';
-import PassportService from '../service/passport';
-import SearchService from '../service/search';
-import { SlackIntegrationService } from '../service/slack-integration';
-import { SocketIoService } from '../service/socket-io';
-import UserGroupService from '../service/user-group';
-import { UserNotificationService } from '../service/user-notification';
-import { initializeYjsService } from '../service/yjs';
-import {
-  getModelSafely,
-  getMongoUri,
-  mongoOptions,
-} from '../util/mongoose-utils';
-import { setupModelsDependentOnCrowi } from './setup-models';
-
-const logger = loggerFactory('growi:crowi');
-const httpErrorHandler = require('../middlewares/http-error-handler');
-
-const sep = path.sep;
-
-class Crowi {
-  /**
-   * For retrieving other packages
-   * @type {import('~/server/middlewares/access-token-parser').AccessTokenParser}
-   */
-  accessTokenParser;
-
-  /** @type {ReturnType<typeof next>} */
-  nextApp;
-
-  /** @type {import('../service/config-manager').IConfigManagerForApp} */
-  configManager;
-
-  /** @type {AttachmentService} */
-  attachmentService;
-
-  /** @type {import('../service/acl').AclService} */
-  aclService;
-
-  /** @type {AppService} */
-  appService;
-
-  /** @type {FileUploader} */
-  fileUploadService;
-
-  /** @type {import('../service/growi-info').GrowiInfoService} */
-  growiInfoService;
-
-  /** @type {import('../service/growi-bridge').GrowiBridgeService} */
-  growiBridgeService;
-
-  /** @type {import('../service/page').IPageService} */
-  pageService;
-
-  /** @type {import('../service/page-grant').default} */
-  pageGrantService;
-
-  /** @type {import('../service/page-operation').IPageOperationService} */
-  pageOperationService;
-
-  /** @type {import('../service/customize').CustomizeService} */
-  customizeService;
-
-  /** @type {PassportService} */
-  passportService;
-
-  /** @type {SearchService} */
-  searchService;
-
-  /** @type {SlackIntegrationService} */
-  slackIntegrationService;
-
-  /** @type {SocketIoService} */
-  socketIoService;
-
-  /** @type UserNotificationService */
-  userNotificationService;
-
-  constructor() {
-    this.version = getGrowiVersion();
-
-    this.publicDir = path.join(projectRoot, 'public') + sep;
-    this.resourceDir = path.join(projectRoot, 'resource') + sep;
-    this.localeDir = path.join(this.resourceDir, 'locales') + sep;
-    this.viewsDir = path.resolve(__dirname, '../views') + sep;
-    this.tmpDir = path.join(projectRoot, 'tmp') + sep;
-    this.cacheDir = path.join(this.tmpDir, 'cache');
-
-    this.express = null;
-
-    this.accessTokenParser = accessTokenParser;
-
-    this.config = {};
-    this.configManager = null;
-    this.s2sMessagingService = null;
-    this.g2gTransferPusherService = null;
-    this.g2gTransferReceiverService = null;
-    this.mailService = null;
-    this.passportService = null;
-    this.globalNotificationService = null;
-    this.aclService = null;
-    this.appService = null;
-    this.fileUploadService = null;
-    this.pluginService = null;
-    this.searchService = null;
-    this.socketIoService = null;
-    this.syncPageStatusService = null;
-    this.slackIntegrationService = null;
-    this.inAppNotificationService = null;
-    this.activityService = null;
-    this.commentService = null;
-    this.openaiThreadDeletionCronService = null;
-    this.openaiVectorStoreFileDeletionCronService = null;
-
-    this.tokens = null;
-
-    /** @type {import('./setup-models').ModelsMapDependentOnCrowi} */
-    this.models = {};
-
-    this.env = process.env;
-    this.node_env = this.env.NODE_ENV || 'development';
-
-    this.port = this.env.PORT || 3000;
-
-    this.events = {
-      user: new UserEvent(this),
-      page: new (require('../events/page'))(this),
-      activity: new (require('../events/activity'))(this),
-      bookmark: new (require('../events/bookmark'))(this),
-      tag: new (require('../events/tag'))(this),
-      admin: new (require('../events/admin'))(this),
-    };
-  }
-}
-
-Crowi.prototype.init = async function () {
-  await this.setupDatabase();
-  this.models = await setupModelsDependentOnCrowi(this);
-  await this.setupConfigManager();
-  await this.setupSessionConfig();
-
-  // setup messaging services
-  await this.setupS2sMessagingService();
-  await this.setupSocketIoService();
-
-  // customizeService depends on AppService
-  // passportService depends on appService
-  // export and import depends on setUpGrowiBridge
-  await Promise.all([this.setUpApp(), this.setUpGrowiBridge()]);
-
-  await Promise.all([
-    this.setupGrowiInfoService(),
-    this.setupPassport(),
-    this.setupSearcher(),
-    this.setupMailer(),
-    this.setupSlackIntegrationService(),
-    this.setupG2GTransferService(),
-    this.setUpFileUpload(),
-    this.setUpFileUploaderSwitchService(),
-    this.setupAttachmentService(),
-    this.setUpAcl(),
-    this.setupUserGroupService(),
-    this.setupExport(),
-    this.setupImport(),
-    this.setupGrowiPluginService(),
-    this.setupPageService(),
-    this.setupInAppNotificationService(),
-    this.setupActivityService(),
-    this.setupCommentService(),
-    this.setupSyncPageStatusService(),
-    this.setUpCustomize(), // depends on pluginService
-  ]);
-
-  await Promise.all([
-    // globalNotification depends on slack and mailer
-    this.setUpGlobalNotification(),
-    this.setUpUserNotification(),
-    // depends on passport service
-    this.setupExternalAccountService(),
-    this.setupExternalUserGroupSyncService(),
-
-    // depends on AttachmentService
-    this.setupOpenaiService(),
-  ]);
-
-  this.setupCron();
-
-  await normalizeData();
-};
-
-/**
- * Execute functions that should be run after the express server is ready.
- */
-Crowi.prototype.asyncAfterExpressServerReady = async function () {
-  if (this.pageOperationService != null) {
-    await this.pageOperationService.afterExpressServerReady();
-  }
-};
-
-Crowi.prototype.isPageId = (pageId) => {
-  if (!pageId) {
-    return false;
-  }
-
-  if (typeof pageId === 'string' && pageId.match(/^[\da-f]{24}$/)) {
-    return true;
-  }
-
-  return false;
-};
-
-Crowi.prototype.setConfig = function (config) {
-  this.config = config;
-};
-
-Crowi.prototype.getConfig = function () {
-  return this.config;
-};
-
-Crowi.prototype.getEnv = function () {
-  return this.env;
-};
-
-/**
- * Wrapper function of mongoose.model()
- * @param {string} modelName
- * @returns {mongoose.Model}
- */
-Crowi.prototype.model = (modelName) => getModelSafely(modelName);
-
-// getter/setter of event instance
-Crowi.prototype.event = function (name, event) {
-  if (event) {
-    this.events[name] = event;
-  }
-
-  return this.events[name];
-};
-
-Crowi.prototype.setupDatabase = () => {
-  mongoose.Promise = global.Promise;
-
-  // mongoUri = mongodb://user:password@host/dbname
-  const mongoUri = getMongoUri();
-
-  return mongoose.connect(mongoUri, mongoOptions);
-};
-
-Crowi.prototype.setupSessionConfig = async function () {
-  const session = require('express-session');
-  const sessionMaxAge =
-    this.configManager.getConfig('security:sessionMaxAge') || 2592000000; // default: 30days
-  const redisUrl =
-    this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null;
-  const uid = require('uid-safe').sync;
-
-  // generate pre-defined uid for healthcheck
-  const healthcheckUid = uid(24);
-
-  const sessionConfig = {
-    rolling: true,
-    secret: this.env.SECRET_TOKEN || 'this is default session secret',
-    resave: false,
-    saveUninitialized: true,
-    cookie: {
-      maxAge: sessionMaxAge,
-    },
-    genid(req) {
-      // return pre-defined uid when healthcheck
-      if (req.path === '/_api/v3/healthcheck') {
-        return healthcheckUid;
-      }
-      return uid(24);
-    },
-  };
-
-  if (this.env.SESSION_NAME) {
-    sessionConfig.name = this.env.SESSION_NAME;
-  }
-
-  // use Redis for session store
-  if (redisUrl) {
-    const redis = require('redis');
-    const redisClient = redis.createClient({ url: redisUrl });
-    const RedisStore = require('connect-redis')(session);
-    sessionConfig.store = new RedisStore({ client: redisClient });
-  }
-  // use MongoDB for session store
-  else {
-    const MongoStore = require('connect-mongo');
-    sessionConfig.store = MongoStore.create({
-      client: mongoose.connection.getClient(),
-    });
-  }
-
-  this.sessionConfig = sessionConfig;
-};
-
-Crowi.prototype.setupConfigManager = async function () {
-  this.configManager = configManagerSingletonInstance;
-  return this.configManager.loadConfigs();
-};
-
-Crowi.prototype.setupS2sMessagingService = async function () {
-  const s2sMessagingService = require('../service/s2s-messaging')(this);
-  if (s2sMessagingService != null) {
-    s2sMessagingService.subscribe();
-    this.configManager.setS2sMessagingService(s2sMessagingService);
-    // add as a message handler
-    s2sMessagingService.addMessageHandler(this.configManager);
-
-    this.s2sMessagingService = s2sMessagingService;
-  }
-};
-
-Crowi.prototype.setupSocketIoService = async function () {
-  this.socketIoService = new SocketIoService(this);
-};
-
-Crowi.prototype.setupCron = function () {
-  instanciatePageBulkExportJobCronService(this);
-  checkPageBulkExportJobInProgressCronService.startCron();
-
-  instanciatePageBulkExportJobCleanUpCronService(this);
-  pageBulkExportJobCleanUpCronService.startCron();
-
-  startOpenaiCronIfEnabled();
-  startAccessTokenCron();
-};
-
-Crowi.prototype.getSlack = function () {
-  return this.slack;
-};
-
-Crowi.prototype.getSlackLegacy = function () {
-  return this.slackLegacy;
-};
-
-Crowi.prototype.getGlobalNotificationService = function () {
-  return this.globalNotificationService;
-};
-
-Crowi.prototype.getUserNotificationService = function () {
-  return this.userNotificationService;
-};
-
-Crowi.prototype.setupPassport = async function () {
-  logger.debug('Passport is enabled');
-
-  // initialize service
-  if (this.passportService == null) {
-    this.passportService = new PassportService(this);
-  }
-  this.passportService.setupSerializer();
-  // setup strategies
-  try {
-    this.passportService.setupStrategyById('local');
-    this.passportService.setupStrategyById('ldap');
-    this.passportService.setupStrategyById('saml');
-    this.passportService.setupStrategyById('oidc');
-    this.passportService.setupStrategyById('google');
-    this.passportService.setupStrategyById('github');
-  } catch (err) {
-    logger.error(err);
-  }
-
-  // add as a message handler
-  if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.passportService);
-  }
-
-  return Promise.resolve();
-};
-
-Crowi.prototype.setupSearcher = async function () {
-  this.searchService = new SearchService(this);
-};
-
-Crowi.prototype.setupMailer = async function () {
-  const MailService = require('~/server/service/mail');
-  this.mailService = new MailService(this);
-
-  // add as a message handler
-  if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.mailService);
-  }
-};
-
-Crowi.prototype.autoInstall = async function () {
-  const isInstalled = this.configManager.getConfig('app:installed');
-  const username = this.configManager.getConfig('autoInstall:adminUsername');
-
-  if (isInstalled || username == null) {
-    return;
-  }
-
-  logger.info('Start automatic installation');
-
-  const firstAdminUserToSave = {
-    username,
-    name: this.configManager.getConfig('autoInstall:adminName'),
-    email: this.configManager.getConfig('autoInstall:adminEmail'),
-    password: this.configManager.getConfig('autoInstall:adminPassword'),
-    admin: true,
-  };
-  const globalLang = this.configManager.getConfig('autoInstall:globalLang');
-  const allowGuestMode = this.configManager.getConfig(
-    'autoInstall:allowGuestMode',
-  );
-  const serverDate = this.configManager.getConfig('autoInstall:serverDate');
-
-  const installerService = new InstallerService(this);
-
-  try {
-    await installerService.install(
-      firstAdminUserToSave,
-      globalLang ?? 'en_US',
-      {
-        allowGuestMode,
-        serverDate,
-      },
-    );
-  } catch (err) {
-    logger.warn('Automatic installation failed.', err);
-  }
-};
-
-Crowi.prototype.getTokens = function () {
-  return this.tokens;
-};
-
-Crowi.prototype.start = async function () {
-  const dev = process.env.NODE_ENV !== 'production';
-
-  await this.init();
-  await this.buildServer();
-
-  // setup Next.js
-  this.nextApp = next({ dev });
-  await this.nextApp.prepare();
-
-  // setup CrowiDev
-  if (dev) {
-    const CrowiDev = require('./dev');
-    this.crowiDev = new CrowiDev(this);
-    this.crowiDev.init();
-  }
-
-  const { express } = this;
-
-  const app =
-    this.node_env === 'development'
-      ? this.crowiDev.setupServer(express)
-      : express;
-
-  const httpServer = http.createServer(app);
-
-  // setup terminus
-  this.setupTerminus(httpServer);
-
-  // attach to socket.io
-  this.socketIoService.attachServer(httpServer);
-
-  // Initialization YjsService
-  initializeYjsService(this.socketIoService.io);
-
-  await this.autoInstall();
-
-  // listen
-  const serverListening = httpServer.listen(this.port, () => {
-    logger.info(
-      `[${this.node_env}] Express server is listening on port ${this.port}`,
-    );
-    if (this.node_env === 'development') {
-      this.crowiDev.setupExpressAfterListening(express);
-    }
-  });
-
-  // setup Express Routes
-  this.setupRoutesForPlugins();
-  this.setupRoutesAtLast();
-
-  // setup Global Error Handlers
-  this.setupGlobalErrorHandlers();
-
-  // Execute this asynchronously after the express server is ready so it does not block the ongoing process
-  this.asyncAfterExpressServerReady();
-
-  return serverListening;
-};
-
-Crowi.prototype.buildServer = async function () {
-  const env = this.node_env;
-  const express = require('express')();
-
-  require('./express-init')(this, express);
-
-  // use bunyan
-  if (env === 'production') {
-    const expressBunyanLogger = require('express-bunyan-logger');
-    const logger = loggerFactory('express');
-    express.use(
-      expressBunyanLogger({
-        logger,
-        excludes: ['*'],
-      }),
-    );
-  }
-  // use morgan
-  else {
-    const morgan = require('morgan');
-    express.use(morgan('dev'));
-  }
-
-  this.express = express;
-};
-
-Crowi.prototype.setupTerminus = (server) => {
-  createTerminus(server, {
-    signals: ['SIGINT', 'SIGTERM'],
-    onSignal: async () => {
-      logger.info('Server is starting cleanup');
-
-      await mongoose.disconnect();
-      return;
-    },
-    onShutdown: async () => {
-      logger.info('Cleanup finished, server is shutting down');
-    },
-  });
-};
-
-Crowi.prototype.setupRoutesForPlugins = function () {
-  lsxRoutes(this, this.express);
-  attachmentRoutes(this, this.express);
-};
-
-/**
- * setup Express Routes
- * !! this must be at last because it includes '/*' route !!
- */
-Crowi.prototype.setupRoutesAtLast = function () {
-  require('../routes')(this, this.express);
-};
-
-/**
- * setup global error handlers
- * !! this must be after the Routes setup !!
- */
-Crowi.prototype.setupGlobalErrorHandlers = function () {
-  this.express.use(httpErrorHandler);
-};
-
-/**
- * require API for plugins
- *
- * @param {string} modulePath relative path from /lib/crowi/index.js
- * @return {module}
- *
- * @memberof Crowi
- */
-Crowi.prototype.require = (modulePath) => require(modulePath);
-
-/**
- * setup GlobalNotificationService
- */
-Crowi.prototype.setUpGlobalNotification = async function () {
-  const GlobalNotificationService = require('../service/global-notification');
-  if (this.globalNotificationService == null) {
-    this.globalNotificationService = new GlobalNotificationService(this);
-  }
-};
-
-/**
- * setup UserNotificationService
- */
-Crowi.prototype.setUpUserNotification = async function () {
-  if (this.userNotificationService == null) {
-    this.userNotificationService = new UserNotificationService(this);
-  }
-};
-
-/**
- * setup AclService
- */
-Crowi.prototype.setUpAcl = async function () {
-  this.aclService = aclServiceSingletonInstance;
-};
-
-/**
- * setup CustomizeService
- */
-Crowi.prototype.setUpCustomize = async function () {
-  const { CustomizeService } = await import('../service/customize');
-  if (this.customizeService == null) {
-    this.customizeService = new CustomizeService(this);
-    this.customizeService.initCustomCss();
-    this.customizeService.initCustomTitle();
-    this.customizeService.initGrowiTheme();
-
-    // add as a message handler
-    if (this.s2sMessagingService != null) {
-      this.s2sMessagingService.addMessageHandler(this.customizeService);
-    }
-  }
-};
-
-/**
- * setup AppService
- */
-Crowi.prototype.setUpApp = async function () {
-  if (this.appService == null) {
-    this.appService = new AppService(this);
-
-    // add as a message handler
-    const isInstalled = this.configManager.getConfig('app:installed');
-    if (this.s2sMessagingService != null && !isInstalled) {
-      this.s2sMessagingService.addMessageHandler(this.appService);
-    }
-  }
-};
-
-/**
- * setup FileUploadService
- */
-Crowi.prototype.setUpFileUpload = async function (isForceUpdate = false) {
-  if (this.fileUploadService == null || isForceUpdate) {
-    this.fileUploadService = getUploader(this);
-  }
-};
-
-/**
- * setup FileUploaderSwitchService
- */
-Crowi.prototype.setUpFileUploaderSwitchService = async function () {
-  const FileUploaderSwitchService = require('../service/file-uploader-switch');
-  this.fileUploaderSwitchService = new FileUploaderSwitchService(this);
-  // add as a message handler
-  if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.fileUploaderSwitchService);
-  }
-};
-
-Crowi.prototype.setupGrowiInfoService = async function () {
-  const { growiInfoService } = await import('../service/growi-info');
-  this.growiInfoService = growiInfoService;
-};
-
-/**
- * setup AttachmentService
- */
-Crowi.prototype.setupAttachmentService = async function () {
-  if (this.attachmentService == null) {
-    this.attachmentService = new AttachmentService(this);
-  }
-};
-
-Crowi.prototype.setupUserGroupService = async function () {
-  if (this.userGroupService == null) {
-    this.userGroupService = new UserGroupService(this);
-    return this.userGroupService.init();
-  }
-};
-
-Crowi.prototype.setUpGrowiBridge = async function () {
-  if (this.growiBridgeService == null) {
-    this.growiBridgeService = new GrowiBridgeService(this);
-  }
-};
-
-Crowi.prototype.setupExport = async function () {
-  instanciateExportService(this);
-};
-
-Crowi.prototype.setupImport = async function () {
-  initializeImportService(this);
-};
-
-Crowi.prototype.setupGrowiPluginService = async () => {
-  const growiPluginService = await import(
-    '~/features/growi-plugin/server/services'
-  ).then((mod) => mod.growiPluginService);
-
-  // download plugin repositories, if document exists but there is no repository
-  // TODO: Cannot download unless connected to the Internet at setup.
-  await growiPluginService.downloadNotExistPluginRepositories();
-};
-
-Crowi.prototype.setupPageService = async function () {
-  if (this.pageGrantService == null) {
-    this.pageGrantService = new PageGrantService(this);
-  }
-  // initialize after pageGrantService since pageService uses pageGrantService in constructor
-  if (this.pageService == null) {
-    this.pageService = new PageService(this);
-    await this.pageService.createTtlIndex();
-  }
-  this.pageOperationService = instanciatePageOperationService(this);
-};
-
-Crowi.prototype.setupInAppNotificationService = async function () {
-  const InAppNotificationService = require('../service/in-app-notification');
-  if (this.inAppNotificationService == null) {
-    this.inAppNotificationService = new InAppNotificationService(this);
-  }
-};
-
-Crowi.prototype.setupActivityService = async function () {
-  const ActivityService = require('../service/activity');
-  if (this.activityService == null) {
-    this.activityService = new ActivityService(this);
-    await this.activityService.createTtlIndex();
-  }
-};
-
-Crowi.prototype.setupCommentService = async function () {
-  const CommentService = require('../service/comment');
-  if (this.commentService == null) {
-    this.commentService = new CommentService(this);
-  }
-};
-
-Crowi.prototype.setupSyncPageStatusService = async function () {
-  const SyncPageStatusService = require('../service/system-events/sync-page-status');
-  if (this.syncPageStatusService == null) {
-    this.syncPageStatusService = new SyncPageStatusService(
-      this,
-      this.s2sMessagingService,
-      this.socketIoService,
-    );
-
-    // add as a message handler
-    if (this.s2sMessagingService != null) {
-      this.s2sMessagingService.addMessageHandler(this.syncPageStatusService);
-    }
-  }
-};
-
-Crowi.prototype.setupSlackIntegrationService = async function () {
-  if (this.slackIntegrationService == null) {
-    this.slackIntegrationService = new SlackIntegrationService(this);
-  }
-
-  // add as a message handler
-  if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.slackIntegrationService);
-  }
-};
-
-Crowi.prototype.setupG2GTransferService = async function () {
-  if (this.g2gTransferPusherService == null) {
-    this.g2gTransferPusherService = new G2GTransferPusherService(this);
-  }
-  if (this.g2gTransferReceiverService == null) {
-    this.g2gTransferReceiverService = new G2GTransferReceiverService(this);
-  }
-};
-
-// execute after setupPassport
-Crowi.prototype.setupExternalAccountService = function () {
-  instanciateExternalAccountService(this.passportService);
-};
-
-// execute after setupPassport, s2sMessagingService, socketIoService
-Crowi.prototype.setupExternalUserGroupSyncService = function () {
-  this.ldapUserGroupSyncService = new LdapUserGroupSyncService(
-    this.passportService,
-    this.s2sMessagingService,
-    this.socketIoService,
-  );
-  this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(
-    this.s2sMessagingService,
-    this.socketIoService,
-  );
-};
-
-Crowi.prototype.setupOpenaiService = function () {
-  initializeOpenaiService(this);
-};
-
-export default Crowi;

+ 895 - 0
apps/app/src/server/crowi/index.ts

@@ -0,0 +1,895 @@
+import next from 'next';
+import http from 'node:http';
+import path from 'node:path';
+import { createTerminus } from '@godaddy/terminus';
+import attachmentRoutes from '@growi/remark-attachment-refs/dist/server';
+import lsxRoutes from '@growi/remark-lsx/dist/server/index.cjs';
+import type { Express } from 'express';
+import mongoose from 'mongoose';
+
+import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
+import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
+import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
+import { initializeOpenaiService } from '~/features/openai/server/services/openai';
+import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
+import instanciatePageBulkExportJobCleanUpCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
+import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
+import { startCron as startAccessTokenCron } from '~/server/service/access-token';
+import { projectRoot } from '~/server/util/project-dir-utils';
+import { getGrowiVersion } from '~/utils/growi-version';
+import loggerFactory from '~/utils/logger';
+
+import ActivityEvent from '../events/activity';
+import AdminEvent from '../events/admin';
+import BookmarkEvent from '../events/bookmark';
+import PageEvent from '../events/page';
+import TagEvent from '../events/tag';
+import UserEvent from '../events/user';
+import type { AccessTokenParser } from '../middlewares/access-token-parser';
+import { accessTokenParser } from '../middlewares/access-token-parser';
+import httpErrorHandler from '../middlewares/http-error-handler';
+import loginRequiredFactory from '../middlewares/login-required';
+import type { AclService } from '../service/acl';
+import { aclService as aclServiceSingletonInstance } from '../service/acl';
+import ActivityService from '../service/activity';
+import AppService from '../service/app';
+import { AttachmentService } from '../service/attachment';
+import CommentService from '../service/comment';
+import { configManager as configManagerSingletonInstance } from '../service/config-manager';
+import type { ConfigManager } from '../service/config-manager/config-manager';
+import instanciateExportService from '../service/export';
+import instanciateExternalAccountService from '../service/external-account';
+import { type FileUploader, getUploader } from '../service/file-uploader';
+import {
+  G2GTransferPusherService,
+  G2GTransferReceiverService,
+} from '../service/g2g-transfer';
+import { GrowiBridgeService } from '../service/growi-bridge';
+import { initializeImportService } from '../service/import';
+import InAppNotificationService from '../service/in-app-notification';
+import { InstallerService } from '../service/installer';
+import { normalizeData } from '../service/normalize-data';
+import PageService from '../service/page';
+import PageGrantService from '../service/page-grant';
+import type { IPageOperationService } from '../service/page-operation';
+import instanciatePageOperationService from '../service/page-operation';
+import PassportService from '../service/passport';
+import SearchService from '../service/search';
+import { SlackIntegrationService } from '../service/slack-integration';
+import { SocketIoService } from '../service/socket-io';
+import SyncPageStatusService from '../service/system-events/sync-page-status';
+import UserGroupService from '../service/user-group';
+import { UserNotificationService } from '../service/user-notification';
+import { initializeYjsService } from '../service/yjs';
+import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
+import type { ModelsMapDependentOnCrowi } from './setup-models';
+import { setupModelsDependentOnCrowi } from './setup-models';
+
+const logger = loggerFactory('growi:crowi');
+
+const sep = path.sep;
+
+type PageEventType = any;
+type ActivityEventType = any;
+type BookmarkEventType = any;
+type TagEventType = any;
+type AdminEventType = any;
+type GlobalNotificationServiceType = any;
+type S2sMessagingServiceType = any;
+type MailServiceType = any;
+type FileUploaderSwitchServiceType = any;
+type InAppNotificationServiceType = any;
+type ActivityServiceType = any;
+type CommentServiceType = any;
+type SyncPageStatusServiceType = any;
+type CrowiDevType = any;
+
+interface SessionConfig {
+  rolling: boolean;
+  secret: string;
+  resave: boolean;
+  saveUninitialized: boolean;
+  cookie: {
+    maxAge: number;
+  };
+  genid: (req: { path: string }) => string;
+  name?: string;
+  store?: unknown;
+}
+
+interface CrowiEvents {
+  user: UserEvent;
+  page: PageEventType;
+  activity: ActivityEventType;
+  bookmark: BookmarkEventType;
+  tag: TagEventType;
+  admin: AdminEventType;
+}
+
+class Crowi {
+  /**
+   * For retrieving other packages
+   */
+  accessTokenParser: AccessTokenParser;
+
+  loginRequiredFactory: typeof loginRequiredFactory;
+
+  nextApp!: ReturnType<typeof next>;
+
+  configManager!: ConfigManager;
+
+  attachmentService!: AttachmentService;
+
+  aclService!: AclService;
+
+  appService!: AppService;
+
+  fileUploadService!: FileUploader;
+
+  growiInfoService!: import('../service/growi-info').GrowiInfoService;
+
+  growiBridgeService!: GrowiBridgeService;
+
+  pageService!: import('../service/page/page-service').IPageService;
+
+  pageGrantService!: PageGrantService;
+
+  pageOperationService!: IPageOperationService;
+
+  customizeService!: import('../service/customize').CustomizeService;
+
+  passportService!: PassportService;
+
+  searchService!: SearchService;
+
+  slackIntegrationService!: SlackIntegrationService;
+
+  socketIoService!: SocketIoService;
+
+  userNotificationService!: UserNotificationService;
+
+  userGroupService!: UserGroupService;
+
+  ldapUserGroupSyncService!: LdapUserGroupSyncService;
+
+  keycloakUserGroupSyncService!: KeycloakUserGroupSyncService;
+
+  globalNotificationService!: GlobalNotificationServiceType;
+
+  sessionConfig!: SessionConfig;
+
+  version: string;
+
+  publicDir: string;
+
+  resourceDir: string;
+
+  localeDir: string;
+
+  viewsDir: string;
+
+  tmpDir: string;
+
+  cacheDir: string;
+
+  express!: Express;
+
+  config: Record<string, unknown>;
+
+  s2sMessagingService: S2sMessagingServiceType | null;
+
+  g2gTransferPusherService: G2GTransferPusherService | null;
+
+  g2gTransferReceiverService: G2GTransferReceiverService | null;
+
+  mailService: MailServiceType | null;
+
+  fileUploaderSwitchService!: FileUploaderSwitchServiceType;
+
+  pluginService: unknown | null;
+
+  syncPageStatusService: SyncPageStatusServiceType | null;
+
+  inAppNotificationService: InAppNotificationServiceType | null;
+
+  activityService: ActivityServiceType | null;
+
+  commentService: CommentServiceType | null;
+
+  openaiThreadDeletionCronService: unknown | null;
+
+  openaiVectorStoreFileDeletionCronService: unknown | null;
+
+  tokens: unknown | null;
+
+  models: ModelsMapDependentOnCrowi;
+
+  env: NodeJS.ProcessEnv;
+
+  node_env: string;
+
+  port: string | number;
+
+  events: CrowiEvents;
+
+  slack?: unknown;
+
+  slackLegacy?: unknown;
+
+  crowiDev?: CrowiDevType;
+
+  constructor() {
+    this.version = getGrowiVersion();
+
+    this.publicDir = path.join(projectRoot, 'public') + sep;
+    this.resourceDir = path.join(projectRoot, 'resource') + sep;
+    this.localeDir = path.join(this.resourceDir, 'locales') + sep;
+    this.viewsDir = path.resolve(__dirname, '../views') + sep;
+    this.tmpDir = path.join(projectRoot, 'tmp') + sep;
+    this.cacheDir = path.join(this.tmpDir, 'cache');
+
+    this.accessTokenParser = accessTokenParser;
+    this.loginRequiredFactory = loginRequiredFactory;
+
+    this.config = {};
+    this.s2sMessagingService = null;
+    this.g2gTransferPusherService = null;
+    this.g2gTransferReceiverService = null;
+    this.mailService = null;
+    this.pluginService = null;
+    this.syncPageStatusService = null;
+    this.inAppNotificationService = null;
+    this.activityService = null;
+    this.commentService = null;
+    this.openaiThreadDeletionCronService = null;
+    this.openaiVectorStoreFileDeletionCronService = null;
+
+    this.tokens = null;
+
+    this.models = {};
+
+    this.env = process.env;
+    this.node_env = this.env.NODE_ENV || 'development';
+
+    this.port = this.env.PORT || 3000;
+
+    this.events = {
+      user: new UserEvent(this),
+      page: new PageEvent(this),
+      activity: new ActivityEvent(this),
+      bookmark: new BookmarkEvent(this),
+      tag: new TagEvent(this),
+      admin: new AdminEvent(this),
+    };
+  }
+
+  async init(): Promise<void> {
+    await this.setupDatabase();
+    this.models = await setupModelsDependentOnCrowi(this);
+    await this.setupConfigManager();
+    await this.setupSessionConfig();
+
+    // setup messaging services
+    await this.setupS2sMessagingService();
+    await this.setupSocketIoService();
+
+    // customizeService depends on AppService
+    // passportService depends on appService
+    // export and import depends on setUpGrowiBridge
+    await Promise.all([this.setUpApp(), this.setUpGrowiBridge()]);
+
+    await Promise.all([
+      this.setupGrowiInfoService(),
+      this.setupPassport(),
+      this.setupSearcher(),
+      this.setupMailer(),
+      this.setupSlackIntegrationService(),
+      this.setupG2GTransferService(),
+      this.setUpFileUpload(),
+      this.setUpFileUploaderSwitchService(),
+      this.setupAttachmentService(),
+      this.setUpAcl(),
+      this.setupUserGroupService(),
+      this.setupExport(),
+      this.setupImport(),
+      this.setupGrowiPluginService(),
+      this.setupPageService(),
+      this.setupInAppNotificationService(),
+      this.setupActivityService(),
+      this.setupCommentService(),
+      this.setupSyncPageStatusService(),
+      this.setUpCustomize(), // depends on pluginService
+    ]);
+
+    await Promise.all([
+      // globalNotification depends on slack and mailer
+      this.setUpGlobalNotification(),
+      this.setUpUserNotification(),
+      // depends on passport service
+      this.setupExternalAccountService(),
+      this.setupExternalUserGroupSyncService(),
+
+      // depends on AttachmentService
+      this.setupOpenaiService(),
+    ]);
+
+    await this.setupCron();
+
+    await normalizeData();
+  }
+
+  /**
+   * Execute functions that should be run after the express server is ready.
+   */
+  async asyncAfterExpressServerReady(): Promise<void> {
+    if (this.pageOperationService != null) {
+      await this.pageOperationService.afterExpressServerReady();
+    }
+  }
+
+  isPageId(pageId: unknown): boolean {
+    if (!pageId) {
+      return false;
+    }
+
+    if (typeof pageId === 'string' && pageId.match(/^[\da-f]{24}$/)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  setConfig(config: Record<string, unknown>): void {
+    this.config = config;
+  }
+
+  getConfig(): Record<string, unknown> {
+    return this.config;
+  }
+
+  getEnv(): NodeJS.ProcessEnv {
+    return this.env;
+  }
+
+  async setupDatabase(): Promise<typeof mongoose> {
+    mongoose.Promise = global.Promise;
+
+    // mongoUri = mongodb://user:password@host/dbname
+    const mongoUri = getMongoUri();
+
+    return mongoose.connect(mongoUri, mongoOptions);
+  }
+
+  async setupSessionConfig(): Promise<void> {
+    const session = require('express-session');
+    const sessionMaxAge =
+      this.configManager.getConfig('security:sessionMaxAge') || 2592000000; // default: 30days
+    const redisUrl =
+      this.env.REDISTOGO_URL ||
+      this.env.REDIS_URI ||
+      this.env.REDIS_URL ||
+      null;
+    const uid = require('uid-safe').sync;
+
+    // generate pre-defined uid for healthcheck
+    const healthcheckUid = uid(24);
+
+    const sessionConfig: SessionConfig = {
+      rolling: true,
+      secret: this.env.SECRET_TOKEN || 'this is default session secret',
+      resave: false,
+      saveUninitialized: true,
+      cookie: {
+        maxAge: sessionMaxAge,
+      },
+      genid(req) {
+        // return pre-defined uid when healthcheck
+        if (req.path === '/_api/v3/healthcheck') {
+          return healthcheckUid;
+        }
+        return uid(24);
+      },
+    };
+
+    if (this.env.SESSION_NAME) {
+      sessionConfig.name = this.env.SESSION_NAME;
+    }
+
+    // use Redis for session store
+    if (redisUrl) {
+      const redis = require('redis');
+      const redisClient = redis.createClient({ url: redisUrl });
+      const RedisStore = require('connect-redis')(session);
+      sessionConfig.store = new RedisStore({ client: redisClient });
+    }
+    // use MongoDB for session store
+    else {
+      const MongoStore = require('connect-mongo');
+      sessionConfig.store = MongoStore.create({
+        client: mongoose.connection.getClient(),
+      });
+    }
+
+    this.sessionConfig = sessionConfig;
+  }
+
+  async setupConfigManager(): Promise<void> {
+    this.configManager = configManagerSingletonInstance;
+    return this.configManager.loadConfigs();
+  }
+
+  async setupS2sMessagingService(): Promise<void> {
+    const s2sMessagingService = require('../service/s2s-messaging')(this);
+    if (s2sMessagingService != null) {
+      s2sMessagingService.subscribe();
+      this.configManager.setS2sMessagingService(s2sMessagingService);
+      // add as a message handler
+      s2sMessagingService.addMessageHandler(this.configManager);
+
+      this.s2sMessagingService = s2sMessagingService;
+    }
+  }
+
+  async setupSocketIoService(): Promise<void> {
+    this.socketIoService = new SocketIoService(this);
+  }
+
+  async setupCron(): Promise<void> {
+    instanciatePageBulkExportJobCronService(this);
+    checkPageBulkExportJobInProgressCronService.startCron();
+
+    instanciatePageBulkExportJobCleanUpCronService(this);
+    // Dynamic import to get the initialized singleton instance
+    const { pageBulkExportJobCleanUpCronService } = await import(
+      '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron'
+    );
+    if (pageBulkExportJobCleanUpCronService == null) {
+      throw new Error('pageBulkExportJobCleanUpCronService is not initialized');
+    }
+    pageBulkExportJobCleanUpCronService.startCron();
+
+    startOpenaiCronIfEnabled();
+    startAccessTokenCron();
+  }
+
+  getSlack(): unknown {
+    return this.slack;
+  }
+
+  getSlackLegacy(): unknown {
+    return this.slackLegacy;
+  }
+
+  async setupPassport(): Promise<void> {
+    logger.debug('Passport is enabled');
+
+    // initialize service
+    if (this.passportService == null) {
+      this.passportService = new PassportService(this);
+    }
+    this.passportService.setupSerializer();
+    // setup strategies
+    try {
+      this.passportService.setupStrategyById('local');
+      this.passportService.setupStrategyById('ldap');
+      this.passportService.setupStrategyById('saml');
+      this.passportService.setupStrategyById('oidc');
+      this.passportService.setupStrategyById('google');
+      this.passportService.setupStrategyById('github');
+    } catch (err) {
+      logger.error(err);
+    }
+
+    // add as a message handler
+    if (this.s2sMessagingService != null) {
+      this.s2sMessagingService.addMessageHandler(this.passportService);
+    }
+  }
+
+  async setupSearcher(): Promise<void> {
+    this.searchService = new SearchService(this);
+  }
+
+  async setupMailer(): Promise<void> {
+    const MailService = require('~/server/service/mail');
+    this.mailService = new MailService(this);
+
+    // add as a message handler
+    if (this.s2sMessagingService != null) {
+      this.s2sMessagingService.addMessageHandler(this.mailService);
+    }
+  }
+
+  async autoInstall(): Promise<void> {
+    const isInstalled = this.configManager.getConfig('app:installed');
+    const username = this.configManager.getConfig('autoInstall:adminUsername');
+
+    if (isInstalled || username == null) {
+      return;
+    }
+
+    logger.info('Start automatic installation');
+
+    const firstAdminUserToSave = {
+      username,
+      name: this.configManager.getConfig('autoInstall:adminName') ?? username,
+      email: this.configManager.getConfig('autoInstall:adminEmail') ?? '',
+      password: this.configManager.getConfig('autoInstall:adminPassword') ?? '',
+      admin: true,
+    };
+    const globalLang = this.configManager.getConfig('autoInstall:globalLang');
+    const allowGuestMode = this.configManager.getConfig(
+      'autoInstall:allowGuestMode',
+    );
+    const serverDateStr = this.configManager.getConfig(
+      'autoInstall:serverDate',
+    );
+    const serverDate =
+      serverDateStr != null ? new Date(serverDateStr) : undefined;
+
+    const installerService = new InstallerService(this);
+
+    try {
+      await installerService.install(
+        firstAdminUserToSave,
+        globalLang ?? 'en_US',
+        {
+          allowGuestMode,
+          serverDate,
+        },
+      );
+    } catch (err) {
+      logger.warn('Automatic installation failed.', err);
+    }
+  }
+
+  getTokens(): unknown {
+    return this.tokens;
+  }
+
+  async start(): Promise<http.Server> {
+    const dev = process.env.NODE_ENV !== 'production';
+
+    await this.init();
+    await this.buildServer();
+
+    // setup Next.js
+    this.nextApp = next({ dev });
+    await this.nextApp.prepare();
+
+    // setup CrowiDev
+    if (dev) {
+      const CrowiDev = require('./dev');
+      this.crowiDev = new CrowiDev(this);
+      this.crowiDev.init();
+    }
+
+    const { express } = this;
+
+    const app =
+      this.node_env === 'development'
+        ? this.crowiDev!.setupServer(express)
+        : express;
+
+    const httpServer = http.createServer(app);
+
+    // setup terminus
+    this.setupTerminus(httpServer);
+
+    // attach to socket.io
+    this.socketIoService.attachServer(httpServer);
+
+    // Initialization YjsService
+    initializeYjsService(this.socketIoService.io);
+
+    await this.autoInstall();
+
+    // listen
+    const serverListening = httpServer.listen(this.port, () => {
+      logger.info(
+        `[${this.node_env}] Express server is listening on port ${this.port}`,
+      );
+      if (this.node_env === 'development') {
+        this.crowiDev!.setupExpressAfterListening(express);
+      }
+    });
+
+    // setup Express Routes
+    this.setupRoutesForPlugins();
+    await this.setupRoutesAtLast();
+
+    // setup Global Error Handlers
+    this.setupGlobalErrorHandlers();
+
+    // Execute this asynchronously after the express server is ready so it does not block the ongoing process
+    this.asyncAfterExpressServerReady();
+
+    return serverListening;
+  }
+
+  async buildServer(): Promise<void> {
+    const env = this.node_env;
+    const express: Express = require('express')();
+
+    require('./express-init')(this, express);
+
+    // use bunyan
+    if (env === 'production') {
+      const expressBunyanLogger = require('express-bunyan-logger');
+      const bunyanLogger = loggerFactory('express');
+      express.use(
+        expressBunyanLogger({
+          logger: bunyanLogger,
+          excludes: ['*'],
+        }),
+      );
+    }
+    // use morgan
+    else {
+      const morgan = require('morgan');
+      express.use(morgan('dev'));
+    }
+
+    this.express = express;
+  }
+
+  setupTerminus(server: http.Server): void {
+    createTerminus(server, {
+      signals: ['SIGINT', 'SIGTERM'],
+      onSignal: async () => {
+        logger.info('Server is starting cleanup');
+
+        await mongoose.disconnect();
+        return;
+      },
+      onShutdown: async () => {
+        logger.info('Cleanup finished, server is shutting down');
+      },
+    });
+  }
+
+  setupRoutesForPlugins(): void {
+    lsxRoutes(this, this.express);
+    attachmentRoutes(this, this.express);
+  }
+
+  /**
+   * setup Express Routes
+   * !! this must be at last because it includes '/*' route !!
+   */
+  async setupRoutesAtLast(): Promise<void> {
+    type RoutesSetup = (crowi: Crowi, app: Express) => void;
+    // CommonJS modules are always wrapped in { default } when dynamically imported
+    const { default: setupRoutes } = (await import('../routes')) as unknown as {
+      default: RoutesSetup;
+    };
+    setupRoutes(this, this.express);
+  }
+
+  /**
+   * setup global error handlers
+   * !! this must be after the Routes setup !!
+   */
+  setupGlobalErrorHandlers(): void {
+    this.express.use(httpErrorHandler);
+  }
+
+  /**
+   * setup GlobalNotificationService
+   */
+  async setUpGlobalNotification(): Promise<void> {
+    const { GlobalNotificationService } = await import(
+      '../service/global-notification'
+    );
+    if (this.globalNotificationService == null) {
+      this.globalNotificationService = new GlobalNotificationService(this);
+    }
+  }
+
+  /**
+   * setup UserNotificationService
+   */
+  async setUpUserNotification(): Promise<void> {
+    if (this.userNotificationService == null) {
+      this.userNotificationService = new UserNotificationService(this);
+    }
+  }
+
+  /**
+   * setup AclService
+   */
+  async setUpAcl(): Promise<void> {
+    this.aclService = aclServiceSingletonInstance;
+  }
+
+  /**
+   * setup CustomizeService
+   */
+  async setUpCustomize(): Promise<void> {
+    const { CustomizeService } = await import('../service/customize');
+    if (this.customizeService == null) {
+      this.customizeService = new CustomizeService(this);
+      this.customizeService.initCustomCss();
+      this.customizeService.initCustomTitle();
+      this.customizeService.initGrowiTheme();
+
+      // add as a message handler
+      if (this.s2sMessagingService != null) {
+        this.s2sMessagingService.addMessageHandler(this.customizeService);
+      }
+    }
+  }
+
+  /**
+   * setup AppService
+   */
+  async setUpApp(): Promise<void> {
+    if (this.appService == null) {
+      this.appService = new AppService(this);
+
+      // add as a message handler
+      const isInstalled = this.configManager.getConfig('app:installed');
+      if (this.s2sMessagingService != null && !isInstalled) {
+        this.s2sMessagingService.addMessageHandler(this.appService);
+      }
+    }
+  }
+
+  /**
+   * setup FileUploadService
+   */
+  async setUpFileUpload(isForceUpdate = false): Promise<void> {
+    if (this.fileUploadService == null || isForceUpdate) {
+      this.fileUploadService = getUploader(this);
+    }
+  }
+
+  /**
+   * setup FileUploaderSwitchService
+   */
+  async setUpFileUploaderSwitchService(): Promise<void> {
+    const FileUploaderSwitchService = require('../service/file-uploader-switch');
+    this.fileUploaderSwitchService = new FileUploaderSwitchService(this);
+    // add as a message handler
+    if (this.s2sMessagingService != null) {
+      this.s2sMessagingService.addMessageHandler(
+        this.fileUploaderSwitchService,
+      );
+    }
+  }
+
+  async setupGrowiInfoService(): Promise<void> {
+    const { growiInfoService } = await import('../service/growi-info');
+    this.growiInfoService = growiInfoService;
+  }
+
+  /**
+   * setup AttachmentService
+   */
+  async setupAttachmentService(): Promise<void> {
+    if (this.attachmentService == null) {
+      this.attachmentService = new AttachmentService(this);
+    }
+  }
+
+  async setupUserGroupService(): Promise<void> {
+    if (this.userGroupService == null) {
+      this.userGroupService = new UserGroupService(this);
+      return this.userGroupService.init();
+    }
+  }
+
+  async setUpGrowiBridge(): Promise<void> {
+    if (this.growiBridgeService == null) {
+      this.growiBridgeService = new GrowiBridgeService(this);
+    }
+  }
+
+  async setupExport(): Promise<void> {
+    instanciateExportService(this);
+  }
+
+  async setupImport(): Promise<void> {
+    initializeImportService(this);
+  }
+
+  async setupGrowiPluginService(): Promise<void> {
+    const growiPluginService = await import(
+      '~/features/growi-plugin/server/services'
+    ).then((mod) => mod.growiPluginService);
+
+    // download plugin repositories, if document exists but there is no repository
+    // TODO: Cannot download unless connected to the Internet at setup.
+    await growiPluginService.downloadNotExistPluginRepositories();
+  }
+
+  async setupPageService(): Promise<void> {
+    if (this.pageGrantService == null) {
+      this.pageGrantService = new PageGrantService(this);
+    }
+    // initialize after pageGrantService since pageService uses pageGrantService in constructor
+    if (this.pageService == null) {
+      this.pageService = new PageService(this);
+      await this.pageService.createTtlIndex();
+    }
+    this.pageOperationService = instanciatePageOperationService(this);
+  }
+
+  async setupInAppNotificationService(): Promise<void> {
+    if (this.inAppNotificationService == null) {
+      this.inAppNotificationService = new InAppNotificationService(this);
+    }
+  }
+
+  async setupActivityService(): Promise<void> {
+    if (this.activityService == null) {
+      this.activityService = new ActivityService(this);
+      await this.activityService.createTtlIndex();
+    }
+  }
+
+  async setupCommentService(): Promise<void> {
+    if (this.commentService == null) {
+      this.commentService = new CommentService(this);
+    }
+  }
+
+  async setupSyncPageStatusService(): Promise<void> {
+    if (this.syncPageStatusService == null) {
+      this.syncPageStatusService = new SyncPageStatusService(
+        this,
+        this.s2sMessagingService,
+        this.socketIoService,
+      );
+
+      // add as a message handler
+      if (this.s2sMessagingService != null) {
+        this.s2sMessagingService.addMessageHandler(this.syncPageStatusService);
+      }
+    }
+  }
+
+  async setupSlackIntegrationService(): Promise<void> {
+    if (this.slackIntegrationService == null) {
+      this.slackIntegrationService = new SlackIntegrationService(this);
+    }
+
+    // add as a message handler
+    if (this.s2sMessagingService != null) {
+      this.s2sMessagingService.addMessageHandler(this.slackIntegrationService);
+    }
+  }
+
+  async setupG2GTransferService(): Promise<void> {
+    if (this.g2gTransferPusherService == null) {
+      this.g2gTransferPusherService = new G2GTransferPusherService(this);
+    }
+    if (this.g2gTransferReceiverService == null) {
+      this.g2gTransferReceiverService = new G2GTransferReceiverService(this);
+    }
+  }
+
+  // execute after setupPassport
+  setupExternalAccountService(): void {
+    instanciateExternalAccountService(this.passportService);
+  }
+
+  // execute after setupPassport, s2sMessagingService, socketIoService
+  setupExternalUserGroupSyncService(): void {
+    this.ldapUserGroupSyncService = new LdapUserGroupSyncService(
+      this.passportService,
+      this.s2sMessagingService,
+      this.socketIoService,
+    );
+    this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(
+      this.s2sMessagingService,
+      this.socketIoService,
+    );
+  }
+
+  setupOpenaiService(): void {
+    initializeOpenaiService(this);
+  }
+}
+
+export default Crowi;

+ 3 - 7
apps/app/src/server/events/activity.ts

@@ -1,12 +1,8 @@
-import loggerFactory from '~/utils/logger';
+import events from 'node:events';
+import util from 'node:util';
 
 import type Crowi from '../crowi';
 
-const logger = loggerFactory('growi:events:activity');
-
-const events = require('events');
-const util = require('util');
-
 function ActivityEvent(crowi: Crowi) {
   this.crowi = crowi;
 
@@ -14,4 +10,4 @@ function ActivityEvent(crowi: Crowi) {
 }
 util.inherits(ActivityEvent, events.EventEmitter);
 
-module.exports = ActivityEvent;
+export default ActivityEvent;

+ 0 - 12
apps/app/src/server/events/admin.js

@@ -1,12 +0,0 @@
-const events = require('events');
-const util = require('util');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function AdminEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(AdminEvent, events.EventEmitter);
-
-module.exports = AdminEvent;

+ 14 - 0
apps/app/src/server/events/admin.ts

@@ -0,0 +1,14 @@
+import EventEmitter from 'node:events';
+
+import type Crowi from '../crowi';
+
+class AdminEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+}
+
+export default AdminEvent;

+ 0 - 15
apps/app/src/server/events/bookmark.js

@@ -1,15 +0,0 @@
-const events = require('events');
-const util = require('util');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function BookmarkEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(BookmarkEvent, events.EventEmitter);
-
-BookmarkEvent.prototype.onCreate = (bookmark) => {};
-BookmarkEvent.prototype.onDelete = (bookmark) => {};
-
-module.exports = BookmarkEvent;

+ 22 - 0
apps/app/src/server/events/bookmark.ts

@@ -0,0 +1,22 @@
+import EventEmitter from 'node:events';
+
+import type Crowi from '../crowi';
+
+class BookmarkEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  onCreate(_bookmark: unknown): void {
+    // placeholder for event handler
+  }
+
+  onDelete(_bookmark: unknown): void {
+    // placeholder for event handler
+  }
+}
+
+export default BookmarkEvent;

+ 0 - 28
apps/app/src/server/events/page.js

@@ -1,28 +0,0 @@
-import events from 'events';
-import util from 'util';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:events:page');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function PageEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(PageEvent, events.EventEmitter);
-
-PageEvent.prototype.onCreate = (page, user) => {
-  logger.debug('onCreate event fired');
-};
-PageEvent.prototype.onUpdate = (page, user) => {
-  logger.debug('onUpdate event fired');
-};
-PageEvent.prototype.onCreateMany = (pages, user) => {
-  logger.debug('onCreateMany event fired');
-};
-PageEvent.prototype.onAddSeenUsers = (pages, user) => {
-  logger.debug('onAddSeenUsers event fired');
-};
-module.exports = PageEvent;

+ 35 - 0
apps/app/src/server/events/page.ts

@@ -0,0 +1,35 @@
+import EventEmitter from 'node:events';
+import type { IPage, IUserHasId } from '@growi/core';
+
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../crowi';
+
+const logger = loggerFactory('growi:events:page');
+
+class PageEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  onCreate(_page: IPage, _user: IUserHasId): void {
+    logger.debug('onCreate event fired');
+  }
+
+  onUpdate(_page: IPage, _user: IUserHasId): void {
+    logger.debug('onUpdate event fired');
+  }
+
+  onCreateMany(_pages: IPage[], _user: IUserHasId): void {
+    logger.debug('onCreateMany event fired');
+  }
+
+  onAddSeenUsers(_pages: IPage[], _user: IUserHasId): void {
+    logger.debug('onAddSeenUsers event fired');
+  }
+}
+
+export default PageEvent;

+ 0 - 14
apps/app/src/server/events/tag.js

@@ -1,14 +0,0 @@
-const events = require('events');
-const util = require('util');
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-function TagEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(TagEvent, events.EventEmitter);
-
-TagEvent.prototype.onUpdate = (tag) => {};
-
-module.exports = TagEvent;

+ 19 - 0
apps/app/src/server/events/tag.ts

@@ -0,0 +1,19 @@
+import EventEmitter from 'node:events';
+import type { ITag } from '@growi/core';
+
+import type Crowi from '../crowi';
+
+class TagEvent extends EventEmitter {
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  onUpdate(_tag: ITag): void {
+    // placeholder for event handler
+  }
+}
+
+export default TagEvent;

+ 6 - 8
apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts

@@ -1,5 +1,6 @@
 import { faker } from '@faker-js/faker';
 import { SCOPE } from '@growi/core/dist/interfaces';
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
 import { mock } from 'vitest-mock-extended';
@@ -9,7 +10,6 @@ import type UserEvent from '~/server/events/user';
 import { AccessToken } from '~/server/models/access-token';
 
 import { parserForAccessToken } from './access-token';
-import type { AccessTokenParserReq } from './interfaces';
 
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 
@@ -19,13 +19,11 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
   beforeAll(async () => {
     const crowiMock = mock<Crowi>({
-      event: vi.fn().mockImplementation((eventName) => {
-        if (eventName === 'user') {
-          return mock<UserEvent>({
-            on: vi.fn(),
-          });
-        }
-      }),
+      events: {
+        user: mock<UserEvent>({
+          on: vi.fn(),
+        }),
+      },
     });
     const userModelFactory = (await import('../../models/user')).default;
     User = userModelFactory(crowiMock);

+ 1 - 1
apps/app/src/server/middlewares/access-token-parser/access-token.ts

@@ -1,4 +1,5 @@
 import type { IUserHasId, Scope } from '@growi/core/dist/interfaces';
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
 
@@ -6,7 +7,6 @@ import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
 import { extractBearerToken } from './extract-bearer-token';
-import type { AccessTokenParserReq } from './interfaces';
 
 const logger = loggerFactory(
   'growi:middleware:access-token-parser:access-token',

+ 6 - 8
apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts

@@ -1,4 +1,5 @@
 import { faker } from '@faker-js/faker';
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
 import { mock } from 'vitest-mock-extended';
@@ -7,7 +8,6 @@ import type Crowi from '~/server/crowi';
 import type UserEvent from '~/server/events/user';
 
 import { parserForApiToken } from './api-token';
-import type { AccessTokenParserReq } from './interfaces';
 
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 
@@ -17,13 +17,11 @@ describe('access-token-parser middleware', () => {
 
   beforeAll(async () => {
     const crowiMock = mock<Crowi>({
-      event: vi.fn().mockImplementation((eventName) => {
-        if (eventName === 'user') {
-          return mock<UserEvent>({
-            on: vi.fn(),
-          });
-        }
-      }),
+      events: {
+        user: mock<UserEvent>({
+          on: vi.fn(),
+        }),
+      },
     });
     const userModelFactory = (await import('../../models/user')).default;
     User = userModelFactory(crowiMock);

+ 1 - 1
apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -1,4 +1,5 @@
 import type { IUser, IUserHasId } from '@growi/core/dist/interfaces';
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
 import type { HydratedDocument } from 'mongoose';
@@ -7,7 +8,6 @@ import mongoose from 'mongoose';
 import loggerFactory from '~/utils/logger';
 
 import { extractBearerToken } from './extract-bearer-token';
-import type { AccessTokenParserReq } from './interfaces';
 
 const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 

+ 5 - 11
apps/app/src/server/middlewares/access-token-parser/index.ts

@@ -1,22 +1,16 @@
-import type { Scope } from '@growi/core/dist/interfaces';
-import type { NextFunction, Response } from 'express';
+import type {
+  AccessTokenParser,
+  AccessTokenParserReq,
+} from '@growi/core/dist/interfaces/server';
 
 import loggerFactory from '~/utils/logger';
 
 import { parserForAccessToken } from './access-token';
 import { parserForApiToken } from './api-token';
-import type { AccessTokenParserReq } from './interfaces';
 
 const logger = loggerFactory('growi:middleware:access-token-parser');
 
-export type AccessTokenParser = (
-  scopes?: Scope[],
-  opts?: { acceptLegacy: boolean },
-) => (
-  req: AccessTokenParserReq,
-  res: Response,
-  next: NextFunction,
-) => Promise<void>;
+export type { AccessTokenParser, AccessTokenParserReq };
 
 export const accessTokenParser: AccessTokenParser = (scopes, opts) => {
   return async (req, res, next): Promise<void> => {

+ 0 - 15
apps/app/src/server/middlewares/access-token-parser/interfaces.ts

@@ -1,15 +0,0 @@
-import type { IUserHasId } from '@growi/core/dist/interfaces';
-import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializers';
-import type { Request } from 'express';
-
-type ReqQuery = {
-  access_token?: string;
-};
-type ReqBody = {
-  access_token?: string;
-};
-
-export interface AccessTokenParserReq
-  extends Request<undefined, undefined, ReqBody, ReqQuery> {
-  user?: IUserSerializedSecurely<IUserHasId>;
-}

+ 20 - 3
apps/app/src/server/middlewares/admin-required.js → apps/app/src/server/middlewares/admin-required.ts

@@ -1,10 +1,25 @@
+import type { IUserHasId } from '@growi/core';
+import type { NextFunction, Request, Response } from 'express';
+
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
+
 const logger = loggerFactory('growi:middleware:admin-required');
 
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-module.exports = (crowi, fallback = null) => {
-  return (req, res, next) => {
+type RequestWithUser = Request & { user?: IUserHasId };
+
+type FallbackFunction = (
+  req: RequestWithUser,
+  res: Response,
+  next: NextFunction,
+) => void;
+
+const adminRequiredFactory = (
+  _crowi: Crowi,
+  fallback: FallbackFunction | null = null,
+) => {
+  return (req: RequestWithUser, res: Response, next: NextFunction) => {
     if (req.user != null && req.user instanceof Object && '_id' in req.user) {
       if (req.user.admin) {
         return next();
@@ -26,3 +41,5 @@ module.exports = (crowi, fallback = null) => {
     return res.redirect('/login');
   };
 };
+
+export default adminRequiredFactory;

+ 1 - 1
apps/app/src/server/middlewares/certify-origin.ts

@@ -4,7 +4,7 @@ import type { NextFunction, Response } from 'express';
 import loggerFactory from '../../utils/logger';
 import { configManager } from '../service/config-manager';
 import isSimpleRequest from '../util/is-simple-request';
-import type { AccessTokenParserReq } from './access-token-parser/interfaces';
+import type { AccessTokenParserReq } from './access-token-parser';
 
 const logger = loggerFactory('growi:middleware:certify-origin');
 

+ 6 - 3
apps/app/src/server/middlewares/http-error-handler.js → apps/app/src/server/middlewares/http-error-handler.ts

@@ -1,10 +1,11 @@
+import type { ErrorRequestHandler } from 'express';
 import { isHttpError } from 'http-errors';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:htto-error-handler');
 
-module.exports = async (err, req, res, next) => {
+const httpErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
   // handle if the err is a HttpError instance
   if (isHttpError(err)) {
     const httpError = err;
@@ -14,10 +15,12 @@ module.exports = async (err, req, res, next) => {
         status: httpError.status,
         message: httpError.message,
       });
-    } catch (err) {
-      logger.error('Cannot call res.send() twice:', err);
+    } catch (e) {
+      logger.error('Cannot call res.send() twice:', e);
     }
   }
 
   next(err);
 };
+
+export default httpErrorHandler;

+ 102 - 94
apps/app/test/integration/middlewares/login-required.test.js → apps/app/src/server/middlewares/login-required.spec.ts

@@ -1,37 +1,47 @@
-const { getInstance } = require('../setup-crowi');
+import type Crowi from '../crowi';
+import { UserStatus } from '../models/user/conts';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type LoginRequiredMiddleware = (req: any, res: any, next: any) => any;
 
 describe('loginRequired', () => {
-  let crowi;
-  const fallbackMock = jest.fn().mockReturnValue('fallback');
+  let fallbackMock: ReturnType<typeof vi.fn>;
+
+  let loginRequiredStrictly: LoginRequiredMiddleware;
+  let loginRequired: LoginRequiredMiddleware;
+  let loginRequiredWithFallback: LoginRequiredMiddleware;
 
-  let loginRequiredStrictly;
-  let loginRequired;
-  let loginRequiredWithFallback;
+  // Mock Crowi with only the required aclService
+  const aclServiceMock = {
+    isGuestAllowedToRead: vi.fn(),
+  };
+  const crowiMock = {
+    aclService: aclServiceMock,
+  } as unknown as Crowi;
 
   beforeEach(async () => {
-    crowi = await getInstance();
-    loginRequiredStrictly = require('~/server/middlewares/login-required')(
-      crowi,
-    );
-    loginRequired = require('~/server/middlewares/login-required')(crowi, true);
-    loginRequiredWithFallback = require('~/server/middlewares/login-required')(
-      crowi,
+    vi.resetAllMocks();
+    fallbackMock = vi.fn().mockReturnValue('fallback');
+
+    // Use dynamic import to load the middleware factory
+    const loginRequiredFactory = (await import('./login-required')).default;
+
+    loginRequiredStrictly = loginRequiredFactory(crowiMock);
+    loginRequired = loginRequiredFactory(crowiMock, true);
+    loginRequiredWithFallback = loginRequiredFactory(
+      crowiMock,
       false,
       fallbackMock,
     );
   });
 
   describe('not strict mode', () => {
-    const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-      sendStatus: jest.fn().mockReturnValue('sendStatus'),
-    };
-    const next = jest.fn().mockReturnValue('next');
-
     describe('and when aclService.isGuestAllowedToRead() returns false', () => {
-      let req;
-
-      let isGuestAllowedToReadSpy;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let req: any;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let res: any;
+      let next: ReturnType<typeof vi.fn>;
 
       beforeEach(() => {
         // setup req
@@ -39,12 +49,13 @@ describe('loginRequired', () => {
           originalUrl: 'original url 1',
           session: {},
         };
-        // reset session object
-        req.session = {};
-        // prepare spy for AclService.isGuestAllowedToRead
-        isGuestAllowedToReadSpy = jest
-          .spyOn(crowi.aclService, 'isGuestAllowedToRead')
-          .mockImplementation(() => false);
+        res = {
+          redirect: vi.fn().mockReturnValue('redirect'),
+          sendStatus: vi.fn().mockReturnValue('sendStatus'),
+        };
+        next = vi.fn().mockReturnValue('next');
+        // prepare mock for AclService.isGuestAllowedToRead
+        aclServiceMock.isGuestAllowedToRead.mockReturnValue(false);
       });
 
       test.each`
@@ -62,7 +73,9 @@ describe('loginRequired', () => {
 
           const result = loginRequired(req, res, next);
 
-          expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+          expect(
+            crowiMock.aclService.isGuestAllowedToRead,
+          ).not.toHaveBeenCalled();
           expect(next).not.toHaveBeenCalled();
           expect(fallbackMock).not.toHaveBeenCalled();
           expect(res.sendStatus).not.toHaveBeenCalled();
@@ -78,7 +91,7 @@ describe('loginRequired', () => {
 
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalled();
         expect(next).not.toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
@@ -93,7 +106,7 @@ describe('loginRequired', () => {
 
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
         expect(next).toHaveBeenCalled();
@@ -103,9 +116,11 @@ describe('loginRequired', () => {
     });
 
     describe('and when aclService.isGuestAllowedToRead() returns true', () => {
-      let req;
-
-      let isGuestAllowedToReadSpy;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let req: any;
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let res: any;
+      let next: ReturnType<typeof vi.fn>;
 
       beforeEach(() => {
         // setup req
@@ -113,12 +128,13 @@ describe('loginRequired', () => {
           originalUrl: 'original url 1',
           session: {},
         };
-        // reset session object
-        req.session = {};
-        // prepare spy for AclService.isGuestAllowedToRead
-        isGuestAllowedToReadSpy = jest
-          .spyOn(crowi.aclService, 'isGuestAllowedToRead')
-          .mockImplementation(() => true);
+        res = {
+          redirect: vi.fn().mockReturnValue('redirect'),
+          sendStatus: vi.fn().mockReturnValue('sendStatus'),
+        };
+        next = vi.fn().mockReturnValue('next');
+        // prepare mock for AclService.isGuestAllowedToRead
+        aclServiceMock.isGuestAllowedToRead.mockReturnValue(true);
       });
 
       test.each`
@@ -136,7 +152,9 @@ describe('loginRequired', () => {
 
           const result = loginRequired(req, res, next);
 
-          expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+          expect(
+            crowiMock.aclService.isGuestAllowedToRead,
+          ).not.toHaveBeenCalled();
           expect(next).not.toHaveBeenCalled();
           expect(fallbackMock).not.toHaveBeenCalled();
           expect(res.sendStatus).not.toHaveBeenCalled();
@@ -150,7 +168,9 @@ describe('loginRequired', () => {
       test('pass guest user', () => {
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalledTimes(
+          1,
+        );
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
         expect(next).toHaveBeenCalled();
@@ -163,7 +183,7 @@ describe('loginRequired', () => {
 
         const result = loginRequired(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(crowiMock.aclService.isGuestAllowedToRead).toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
         expect(next).toHaveBeenCalled();
@@ -174,27 +194,22 @@ describe('loginRequired', () => {
   });
 
   describe('strict mode', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: null,
-    };
-    const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-      sendStatus: jest.fn().mockReturnValue('sendStatus'),
-    };
-    const next = jest.fn().mockReturnValue('next');
-
-    let isGuestAllowedToReadSpy;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let req: any;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let res: any;
+    let next: ReturnType<typeof vi.fn>;
 
     beforeEach(() => {
-      // reset session object
-      req.session = {};
-      // spy for AclService.isGuestAllowedToRead
-      isGuestAllowedToReadSpy = jest.spyOn(
-        crowi.aclService,
-        'isGuestAllowedToRead',
-      );
+      req = {
+        originalUrl: 'original url 1',
+        session: {},
+      };
+      res = {
+        redirect: vi.fn().mockReturnValue('redirect'),
+        sendStatus: vi.fn().mockReturnValue('sendStatus'),
+      };
+      next = vi.fn().mockReturnValue('next');
     });
 
     test("send status 403 when 'req.baseUrl' starts with '_api'", () => {
@@ -202,7 +217,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
@@ -216,7 +231,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -227,16 +242,14 @@ describe('loginRequired', () => {
     });
 
     test('pass user who logged in', () => {
-      const User = crowi.model('User');
-
       req.user = {
         _id: 'user id',
-        status: User.STATUS_ACTIVE,
+        status: UserStatus.STATUS_ACTIVE,
       };
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
@@ -260,7 +273,9 @@ describe('loginRequired', () => {
 
         const result = loginRequiredStrictly(req, res, next);
 
-        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(
+          crowiMock.aclService.isGuestAllowedToRead,
+        ).not.toHaveBeenCalled();
         expect(next).not.toHaveBeenCalled();
         expect(fallbackMock).not.toHaveBeenCalled();
         expect(res.sendStatus).not.toHaveBeenCalled();
@@ -272,17 +287,15 @@ describe('loginRequired', () => {
     );
 
     test("redirect to '/login' when user.status is 'STATUS_DELETED'", () => {
-      const User = crowi.model('User');
-
       req.baseUrl = '/path/that/requires/loggedin';
       req.user = {
         _id: 'user id',
-        status: User.STATUS_DELETED,
+        status: UserStatus.STATUS_DELETED,
       };
 
       const result = loginRequiredStrictly(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -294,27 +307,22 @@ describe('loginRequired', () => {
   });
 
   describe('specified fallback', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: null,
-    };
-    const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-      sendStatus: jest.fn().mockReturnValue('sendStatus'),
-    };
-    const next = jest.fn().mockReturnValue('next');
-
-    let isGuestAllowedToReadSpy;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let req: any;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let res: any;
+    let next: ReturnType<typeof vi.fn>;
 
     beforeEach(() => {
-      // reset session object
-      req.session = {};
-      // spy for AclService.isGuestAllowedToRead
-      isGuestAllowedToReadSpy = jest.spyOn(
-        crowi.aclService,
-        'isGuestAllowedToRead',
-      );
+      req = {
+        originalUrl: 'original url 1',
+        session: {},
+      };
+      res = {
+        redirect: vi.fn().mockReturnValue('redirect'),
+        sendStatus: vi.fn().mockReturnValue('sendStatus'),
+      };
+      next = vi.fn().mockReturnValue('next');
     });
 
     test("invoke fallback when 'req.path' starts with '_api'", () => {
@@ -322,7 +330,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredWithFallback(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
@@ -336,7 +344,7 @@ describe('loginRequired', () => {
 
       const result = loginRequiredWithFallback(req, res, next);
 
-      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(crowiMock.aclService.isGuestAllowedToRead).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();

+ 34 - 9
apps/app/src/server/middlewares/login-required.js → apps/app/src/server/middlewares/login-required.ts

@@ -1,21 +1,42 @@
+import type { IUserHasId } from '@growi/core';
+import type { NextFunction, Request, Response } from 'express';
+
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
+import { UserStatus } from '../models/user/conts';
+
 const logger = loggerFactory('growi:middleware:login-required');
 
+type RequestWithUser = Request & {
+  user?: IUserHasId;
+  isSharedPage?: boolean;
+  isBrandLogo?: boolean;
+  session?: { redirectTo?: string };
+};
+
+type FallbackFunction = (
+  req: RequestWithUser,
+  res: Response,
+  next: NextFunction,
+) => void;
+
 /**
  * require login handler
- * @param {import('~/server/crowi').default} crowi Crowi instance
- * @param {boolean} isGuestAllowed whether guest user is allowed (default false)
- * @param {function} fallback fallback function which will be triggered when the check cannot be passed
+ * @param crowi Crowi instance
+ * @param isGuestAllowed whether guest user is allowed (default false)
+ * @param fallback fallback function which will be triggered when the check cannot be passed
  */
-module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
-  return (req, res, next) => {
-    const User = crowi.model('User');
-
+const loginRequiredFactory = (
+  crowi: Crowi,
+  isGuestAllowed = false,
+  fallback: FallbackFunction | null = null,
+) => {
+  return (req: RequestWithUser, res: Response, next: NextFunction) => {
     // check the user logged in
     if (req.user != null && req.user instanceof Object && '_id' in req.user) {
-      if (req.user.status === User.STATUS_ACTIVE) {
+      if (req.user.status === UserStatus.STATUS_ACTIVE) {
         // Active の人だけ先に進める
         return next();
       }
@@ -55,7 +76,11 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
     if (fallback != null) {
       return fallback(req, res, next);
     }
-    req.session.redirectTo = req.originalUrl;
+    if (req.session != null) {
+      req.session.redirectTo = req.originalUrl;
+    }
     return res.redirect('/login');
   };
 };
+
+export default loginRequiredFactory;

+ 0 - 38
apps/app/src/server/models/GlobalNotificationSetting.ts

@@ -1,38 +0,0 @@
-const mongoose = require('mongoose');
-
-const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
-
-const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
-const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
-
-/**
- * global notifcation event master
- */
-export const GlobalNotificationSettingEvent = {
-  PAGE_CREATE: 'pageCreate',
-  PAGE_EDIT: 'pageEdit',
-  PAGE_DELETE: 'pageDelete',
-  PAGE_MOVE: 'pageMove',
-  PAGE_LIKE: 'pageLike',
-  COMMENT: 'comment',
-};
-
-/**
- * global notifcation type master
- */
-export const GlobalNotificationSettingType = {
-  MAIL: 'mail',
-  SLACK: 'slack',
-};
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-const factory = (crowi) => {
-  GlobalNotificationSettingClass.crowi = crowi;
-  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
-  return mongoose.model(
-    'GlobalNotificationSetting',
-    GlobalNotificationSettingSchema,
-  );
-};
-
-export default factory;

+ 0 - 35
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -1,35 +0,0 @@
-import mongoose from 'mongoose';
-
-import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
-
-const GlobalNotificationSetting = require('./index');
-
-const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
-const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-const factory = (crowi) => {
-  GlobalNotificationSettingClass.crowi = crowi;
-  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
-
-  const GlobalNotificationSettingModel = mongoose.model(
-    'GlobalNotificationSetting',
-    GlobalNotificationSettingSchema,
-  );
-  const GlobalNotificationMailSettingModel =
-    GlobalNotificationSettingModel.discriminator(
-      GlobalNotificationSettingType.MAIL,
-      new mongoose.Schema(
-        {
-          toEmail: String,
-        },
-        {
-          discriminatorKey: 'type',
-        },
-      ),
-    );
-
-  return GlobalNotificationMailSettingModel;
-};
-
-export default factory;

+ 44 - 0
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.ts

@@ -0,0 +1,44 @@
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+
+import { GlobalNotificationSettingType } from './consts';
+import {
+  class as GlobalNotificationSettingClass,
+  schema as GlobalNotificationSettingSchema,
+} from './index';
+import type {
+  GlobalNotificationMailSettingModel,
+  GlobalNotificationSettingModel,
+  IGlobalNotificationMailSetting,
+  IGlobalNotificationSetting,
+} from './types';
+
+const factory = (crowi: Crowi): GlobalNotificationMailSettingModel => {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+
+  const GlobalNotificationSettingModel = mongoose.model<
+    IGlobalNotificationSetting,
+    GlobalNotificationSettingModel
+  >('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  const GlobalNotificationMailSettingModel =
+    GlobalNotificationSettingModel.discriminator<
+      IGlobalNotificationMailSetting,
+      GlobalNotificationMailSettingModel
+    >(
+      GlobalNotificationSettingType.MAIL,
+      new mongoose.Schema(
+        {
+          toEmail: String,
+        },
+        {
+          discriminatorKey: 'type',
+        },
+      ),
+    );
+
+  return GlobalNotificationMailSettingModel;
+};
+
+export default factory;

+ 0 - 35
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -1,35 +0,0 @@
-import mongoose from 'mongoose';
-
-import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
-
-const GlobalNotificationSetting = require('./index');
-
-const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
-const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-const factory = (crowi) => {
-  GlobalNotificationSettingClass.crowi = crowi;
-  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
-
-  const GlobalNotificationSettingModel = mongoose.model(
-    'GlobalNotificationSetting',
-    GlobalNotificationSettingSchema,
-  );
-  const GlobalNotificationSlackSettingModel =
-    GlobalNotificationSettingModel.discriminator(
-      GlobalNotificationSettingType.SLACK,
-      new mongoose.Schema(
-        {
-          slackChannels: String,
-        },
-        {
-          discriminatorKey: 'type',
-        },
-      ),
-    );
-
-  return GlobalNotificationSlackSettingModel;
-};
-
-export default factory;

+ 44 - 0
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.ts

@@ -0,0 +1,44 @@
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+
+import { GlobalNotificationSettingType } from './consts';
+import {
+  class as GlobalNotificationSettingClass,
+  schema as GlobalNotificationSettingSchema,
+} from './index';
+import type {
+  GlobalNotificationSettingModel,
+  GlobalNotificationSlackSettingModel,
+  IGlobalNotificationSetting,
+  IGlobalNotificationSlackSetting,
+} from './types';
+
+const factory = (crowi: Crowi): GlobalNotificationSlackSettingModel => {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+
+  const GlobalNotificationSettingModel = mongoose.model<
+    IGlobalNotificationSetting,
+    GlobalNotificationSettingModel
+  >('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  const GlobalNotificationSlackSettingModel =
+    GlobalNotificationSettingModel.discriminator<
+      IGlobalNotificationSlackSetting,
+      GlobalNotificationSlackSettingModel
+    >(
+      GlobalNotificationSettingType.SLACK,
+      new mongoose.Schema(
+        {
+          slackChannels: String,
+        },
+        {
+          discriminatorKey: 'type',
+        },
+      ),
+    );
+
+  return GlobalNotificationSlackSettingModel;
+};
+
+export default factory;

+ 3 - 3
apps/app/src/server/models/GlobalNotificationSetting/consts.ts

@@ -8,12 +8,12 @@ export const GlobalNotificationSettingEvent = {
   PAGE_MOVE: 'pageMove',
   PAGE_LIKE: 'pageLike',
   COMMENT: 'comment',
-};
+} as const;
 
 /**
  * global notifcation type master
  */
-export const GlobalNotificationSettingEventType = {
+export const GlobalNotificationSettingType = {
   MAIL: 'mail',
   SLACK: 'slack',
-};
+} as const;

+ 0 - 122
apps/app/src/server/models/GlobalNotificationSetting/index.js

@@ -1,122 +0,0 @@
-const nodePath = require('path');
-
-const { pathUtils } = require('@growi/core/dist/utils');
-const mongoose = require('mongoose');
-
-/**
- * parent schema for GlobalNotificationSetting model
- */
-const globalNotificationSettingSchema = new mongoose.Schema({
-  isEnabled: { type: Boolean, required: true, default: true },
-  triggerPath: { type: String, required: true },
-  triggerEvents: { type: [String] },
-});
-
-/*
- * e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
- */
-const generatePathsOnTree = (path, pathList) => {
-  pathList.push(path);
-
-  if (path === '/') {
-    return pathList;
-  }
-
-  const newPath = nodePath.posix.dirname(path);
-
-  return generatePathsOnTree(newPath, pathList);
-};
-
-/*
- * e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
- */
-const generatePathsToMatch = (originalPath) => {
-  const pathList = generatePathsOnTree(originalPath, []);
-  return pathList.map((path) => {
-    // except for the original trigger path ("/a/b/c"), append "*" to find all matches
-    // e.g. ["/a/b/c", "/a/b", "/a", "/"] => ["/a/b/c", "/a/b/*", "/a/*", "/*"]
-    if (path !== originalPath) {
-      return `${pathUtils.addTrailingSlash(path)}*`;
-    }
-
-    return path;
-  });
-};
-
-/**
- * GlobalNotificationSetting Class
- * @class GlobalNotificationSetting
- */
-class GlobalNotificationSetting {
-  /** @type {import('~/server/crowi').default} Crowi instance */
-  crowi;
-
-  /** @param {import('~/server/crowi').default} crowi Crowi instance */
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
-  /**
-   * enable notification setting
-   * @param {string} id
-   */
-  static async enable(id) {
-    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
-    const setting = await this.findOne({ _id: id });
-
-    setting.isEnabled = true;
-    setting.save();
-
-    return setting;
-  }
-
-  /**
-   * disable notification setting
-   * @param {string} id
-   */
-  static async disable(id) {
-    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
-    const setting = await this.findOne({ _id: id });
-
-    setting.isEnabled = false;
-    setting.save();
-
-    return setting;
-  }
-
-  /**
-   * find all notification settings
-   */
-  static async findAll() {
-    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
-    const settings = await this.find().sort({
-      triggerPath: 1,
-    });
-
-    return settings;
-  }
-
-  /**
-   * find a list of notification settings by path and a list of events
-   * @param {string} path
-   * @param {string} event
-   */
-  static async findSettingByPathAndEvent(event, path, type) {
-    const pathsToMatch = generatePathsToMatch(path);
-
-    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
-    const settings = await this.find({
-      triggerPath: { $in: pathsToMatch },
-      triggerEvents: event,
-      __t: type,
-      isEnabled: true,
-    }).sort({ triggerPath: 1 });
-
-    return settings;
-  }
-}
-
-module.exports = {
-  class: GlobalNotificationSetting,
-  schema: globalNotificationSettingSchema,
-};

+ 195 - 0
apps/app/src/server/models/GlobalNotificationSetting/index.ts

@@ -0,0 +1,195 @@
+import nodePath from 'node:path';
+import { pathUtils } from '@growi/core/dist/utils';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+
+import type { GlobalNotificationSettingType } from './consts';
+import type {
+  GlobalNotificationSettingDocument,
+  GlobalNotificationSettingModel,
+  IGlobalNotificationMailSetting,
+  IGlobalNotificationSetting,
+  IGlobalNotificationSlackSetting,
+} from './types';
+
+/**
+ * parent schema for GlobalNotificationSetting model
+ */
+const globalNotificationSettingSchema = new mongoose.Schema<
+  IGlobalNotificationSetting,
+  GlobalNotificationSettingModel
+>({
+  isEnabled: { type: Boolean, required: true, default: true },
+  triggerPath: { type: String, required: true },
+  triggerEvents: { type: [String] },
+});
+
+/*
+ * e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
+ */
+const generatePathsOnTree = (path: string, pathList: string[]): string[] => {
+  pathList.push(path);
+
+  if (path === '/') {
+    return pathList;
+  }
+
+  const newPath = nodePath.posix.dirname(path);
+
+  return generatePathsOnTree(newPath, pathList);
+};
+
+/*
+ * e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
+ */
+const generatePathsToMatch = (originalPath: string): string[] => {
+  const pathList = generatePathsOnTree(originalPath, []);
+  return pathList.map((path) => {
+    // except for the original trigger path ("/a/b/c"), append "*" to find all matches
+    // e.g. ["/a/b/c", "/a/b", "/a", "/"] => ["/a/b/c", "/a/b/*", "/a/*", "/*"]
+    if (path !== originalPath) {
+      return `${pathUtils.addTrailingSlash(path)}*`;
+    }
+
+    return path;
+  });
+};
+
+/**
+ * GlobalNotificationSetting Class
+ * @class GlobalNotificationSetting
+ */
+class GlobalNotificationSetting {
+  static crowi: Crowi;
+
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * enable notification setting
+   */
+  static async enable(
+    this: GlobalNotificationSettingModel,
+    id: string,
+  ): Promise<GlobalNotificationSettingDocument> {
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
+    const setting = await this.findOne({ _id: id });
+
+    if (setting == null) {
+      throw new Error(`GlobalNotificationSetting with id ${id} not found`);
+    }
+
+    setting.isEnabled = true;
+    await setting.save();
+
+    return setting;
+  }
+
+  /**
+   * disable notification setting
+   */
+  static async disable(
+    this: GlobalNotificationSettingModel,
+    id: string,
+  ): Promise<GlobalNotificationSettingDocument> {
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
+    const setting = await this.findOne({ _id: id });
+
+    if (setting == null) {
+      throw new Error(`GlobalNotificationSetting with id ${id} not found`);
+    }
+
+    setting.isEnabled = false;
+    await setting.save();
+
+    return setting;
+  }
+
+  /**
+   * find all notification settings
+   */
+  static async findAll(
+    this: GlobalNotificationSettingModel,
+  ): Promise<GlobalNotificationSettingDocument[]> {
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
+    const settings = await this.find().sort({
+      triggerPath: 1,
+    });
+
+    return settings;
+  }
+
+  /**
+   * find a list of notification settings by path and a list of events
+   */
+  static async findSettingByPathAndEvent(
+    this: GlobalNotificationSettingModel,
+    event: string,
+    path: string,
+    type: typeof GlobalNotificationSettingType.SLACK,
+  ): Promise<
+    (GlobalNotificationSettingDocument & IGlobalNotificationSlackSetting)[]
+  >;
+  static async findSettingByPathAndEvent(
+    this: GlobalNotificationSettingModel,
+    event: string,
+    path: string,
+    type: typeof GlobalNotificationSettingType.MAIL,
+  ): Promise<
+    (GlobalNotificationSettingDocument & IGlobalNotificationMailSetting)[]
+  >;
+  static async findSettingByPathAndEvent(
+    this: GlobalNotificationSettingModel,
+    event: string,
+    path: string,
+    type: string,
+  ): Promise<GlobalNotificationSettingDocument[]> {
+    const pathsToMatch = generatePathsToMatch(path);
+
+    // biome-ignore lint/complexity/noThisInStatic: 'this' refers to the mongoose model here, not the class defined in this file
+    const settings = await this.find({
+      triggerPath: { $in: pathsToMatch },
+      triggerEvents: event,
+      __t: type,
+      isEnabled: true,
+    }).sort({ triggerPath: 1 });
+
+    return settings;
+  }
+}
+
+const factory = (crowi: Crowi): GlobalNotificationSettingModel => {
+  GlobalNotificationSetting.crowi = crowi;
+  globalNotificationSettingSchema.loadClass(GlobalNotificationSetting);
+  return mongoose.model<
+    IGlobalNotificationSetting,
+    GlobalNotificationSettingModel
+  >('GlobalNotificationSetting', globalNotificationSettingSchema);
+};
+
+export default factory;
+
+// Re-export types and constants for external use
+export {
+  GlobalNotificationSettingEvent,
+  GlobalNotificationSettingType,
+} from './consts';
+export type {
+  GlobalNotificationMailSettingModel,
+  GlobalNotificationSettingDocument,
+  GlobalNotificationSettingModel,
+  GlobalNotificationSlackSettingModel,
+  IGlobalNotificationMailSetting,
+  IGlobalNotificationSetting,
+  IGlobalNotificationSlackSetting,
+} from './types';
+
+// Internal use only
+export {
+  GlobalNotificationSetting as class,
+  globalNotificationSettingSchema as schema,
+};

+ 62 - 0
apps/app/src/server/models/GlobalNotificationSetting/types.d.ts

@@ -0,0 +1,62 @@
+import type { HydratedDocument, Model } from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+
+import type { GlobalNotificationSettingType } from './consts';
+
+export interface IGlobalNotificationSetting {
+  isEnabled: boolean;
+  triggerPath: string;
+  triggerEvents: string[];
+}
+
+export type GlobalNotificationSettingDocument =
+  HydratedDocument<IGlobalNotificationSetting>;
+
+export interface GlobalNotificationSettingModel
+  extends Model<IGlobalNotificationSetting> {
+  enable(id: string): Promise<GlobalNotificationSettingDocument>;
+  disable(id: string): Promise<GlobalNotificationSettingDocument>;
+  findAll(): Promise<GlobalNotificationSettingDocument[]>;
+  findSettingByPathAndEvent(
+    event: string,
+    path: string,
+    type: typeof GlobalNotificationSettingType.SLACK,
+  ): Promise<
+    (GlobalNotificationSettingDocument & IGlobalNotificationSlackSetting)[]
+  >;
+  findSettingByPathAndEvent(
+    event: string,
+    path: string,
+    type: typeof GlobalNotificationSettingType.MAIL,
+  ): Promise<
+    (GlobalNotificationSettingDocument & IGlobalNotificationMailSetting)[]
+  >;
+}
+
+export interface IGlobalNotificationMailSetting
+  extends IGlobalNotificationSetting {
+  toEmail: string;
+}
+
+export type GlobalNotificationMailSettingModel =
+  Model<IGlobalNotificationMailSetting> & GlobalNotificationSettingModel;
+
+export interface IGlobalNotificationSlackSetting
+  extends IGlobalNotificationSetting {
+  slackChannels: string;
+}
+
+export type GlobalNotificationSlackSettingModel =
+  Model<IGlobalNotificationSlackSetting> & GlobalNotificationSettingModel;
+
+/**
+ * GlobalNotificationSetting Class
+ * @class GlobalNotificationSetting
+ */
+export declare class GlobalNotificationSetting {
+  static crowi: Crowi;
+  crowi: Crowi;
+
+  constructor(crowi: Crowi);
+}

+ 1 - 1
apps/app/src/server/models/bookmark.ts

@@ -41,7 +41,7 @@ export interface BookmarkModel extends Model<BookmarkDocument> {
 }
 
 const factory = (crowi: Crowi) => {
-  const bookmarkEvent = crowi.event('bookmark');
+  const bookmarkEvent = crowi.events.bookmark;
 
   const bookmarkSchema = new Schema<BookmarkDocument, BookmarkModel>(
     {

+ 2 - 1
apps/app/src/server/models/external-account.ts

@@ -13,6 +13,7 @@ import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
+import { UserStatus } from './user/conts';
 
 const logger = loggerFactory('growi:models:external-account');
 
@@ -127,7 +128,7 @@ schema.statics.findOrRegister = function (
           mailToBeRegistered,
           undefined,
           undefined,
-          User.STATUS_ACTIVE,
+          UserStatus.STATUS_ACTIVE,
         );
       })
       .then((newUser) => {

+ 7 - 8
apps/app/src/server/models/obsolete-page.js

@@ -14,6 +14,7 @@ import ExternalUserGroupRelation from '~/features/external-user-group/server/mod
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../service/config-manager';
+import { USER_FIELDS_EXCEPT_CONFIDENTIAL } from './user/conts';
 import UserGroup from './user-group';
 import UserGroupRelation from './user-group-relation';
 
@@ -97,7 +98,7 @@ export const getPageSchema = (crowi) => {
 
   // init event
   if (crowi != null) {
-    pageEvent = crowi.event('page');
+    pageEvent = crowi.events.page;
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('update', pageEvent.onUpdate);
     pageEvent.on('createMany', pageEvent.onCreateMany);
@@ -254,7 +255,7 @@ export const getPageSchema = (crowi) => {
     return this.save();
   };
 
-  pageSchema.methods.initLatestRevisionField = async function (revisionId) {
+  pageSchema.methods.initLatestRevisionField = function (revisionId) {
     this.latestRevision = this.revision;
     if (revisionId != null) {
       this.revision = revisionId;
@@ -266,10 +267,9 @@ export const getPageSchema = (crowi) => {
   ) {
     validateCrowi();
 
-    const User = crowi.model('User');
-    return populateDataToShowRevision(
+    return await populateDataToShowRevision(
       this,
-      User.USER_FIELDS_EXCEPT_CONFIDENTIAL,
+      USER_FIELDS_EXCEPT_CONFIDENTIAL,
       shouldExcludeBody,
     );
   };
@@ -282,7 +282,7 @@ export const getPageSchema = (crowi) => {
       this.revision = revisionId;
     }
     // biome-ignore lint/plugin: populating is the purpose of this method
-    return this.populate('revision');
+    return await this.populate('revision');
   };
 
   pageSchema.methods.applyScope = function (user, grant, grantUserGroupIds) {
@@ -525,7 +525,6 @@ export const getPageSchema = (crowi) => {
   ) {
     validateCrowi();
 
-    const User = crowi.model('User');
     const opt = Object.assign({ sort: 'updatedAt', desc: -1 }, option);
     const sortOpt = {};
     sortOpt[opt.sort] = opt.desc;
@@ -547,7 +546,7 @@ export const getPageSchema = (crowi) => {
 
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
-    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+    builder.populateDataToList(USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.lean().clone().exec('find');
     const result = {
       pages,

+ 13 - 25
apps/app/test/integration/models/page-redirect.test.js → apps/app/src/server/models/page-redirect.integ.ts

@@ -1,18 +1,6 @@
-import mongoose from 'mongoose';
-
-import { getInstance } from '../setup-crowi';
+import PageRedirect from './page-redirect';
 
 describe('PageRedirect', () => {
-  // biome-ignore lint/correctness/noUnusedVariables: ignore
-  let crowi;
-  let PageRedirect;
-
-  beforeAll(async () => {
-    crowi = await getInstance();
-
-    PageRedirect = mongoose.model('PageRedirect');
-  });
-
   beforeEach(async () => {
     // clear collection
     await PageRedirect.deleteMany({});
@@ -91,12 +79,12 @@ describe('PageRedirect', () => {
 
       // then:
       expect(endpoints).not.toBeNull();
-      expect(endpoints.start).not.toBeNull();
-      expect(endpoints.start.fromPath).toEqual('/path1');
-      expect(endpoints.start.toPath).toEqual('/path2');
-      expect(endpoints.end).not.toBeNull();
-      expect(endpoints.end.fromPath).toEqual('/path1');
-      expect(endpoints.end.toPath).toEqual('/path2');
+      expect(endpoints?.start).not.toBeNull();
+      expect(endpoints?.start.fromPath).toEqual('/path1');
+      expect(endpoints?.start.toPath).toEqual('/path2');
+      expect(endpoints?.end).not.toBeNull();
+      expect(endpoints?.end.fromPath).toEqual('/path1');
+      expect(endpoints?.end.toPath).toEqual('/path2');
     });
 
     test('shoud return IPageRedirectEnds', async () => {
@@ -117,12 +105,12 @@ describe('PageRedirect', () => {
 
       // then:
       expect(endpoints).not.toBeNull();
-      expect(endpoints.start).not.toBeNull();
-      expect(endpoints.start.fromPath).toEqual('/path1');
-      expect(endpoints.start.toPath).toEqual('/path2');
-      expect(endpoints.end).not.toBeNull();
-      expect(endpoints.end.fromPath).toEqual('/path3');
-      expect(endpoints.end.toPath).toEqual('/path4');
+      expect(endpoints?.start).not.toBeNull();
+      expect(endpoints?.start.fromPath).toEqual('/path1');
+      expect(endpoints?.start.toPath).toEqual('/path2');
+      expect(endpoints?.end).not.toBeNull();
+      expect(endpoints?.end.fromPath).toEqual('/path3');
+      expect(endpoints?.end.toPath).toEqual('/path4');
     });
   });
 });

+ 75 - 32
apps/app/test/integration/models/page.test.js → apps/app/src/server/models/page.integ.ts

@@ -1,31 +1,74 @@
-const mongoose = require('mongoose');
+import { EventEmitter } from 'node:events';
+import { mock } from 'vitest-mock-extended';
 
-const { getInstance } = require('../setup-crowi');
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 
-let testUser0;
-let testUser1;
-let testUser2;
-let testGroup0;
-let parentPage;
+// Minimal mock for PageEvent that extends EventEmitter
+class MockPageEvent extends EventEmitter {
+  onCreate = vi.fn();
+
+  onUpdate = vi.fn();
+
+  onCreateMany = vi.fn();
+
+  onAddSeenUsers = vi.fn();
+}
 
 describe('Page', () => {
-  // biome-ignore lint/correctness/noUnusedVariables: ignore
-  let crowi;
-  let Page;
-  let PageQueryBuilder;
-  let User;
-  let UserGroup;
-  let UserGroupRelation;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let Page: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let PageQueryBuilder: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let User: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let UserGroup: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let UserGroupRelation: any;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testUser0: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testUser1: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testUser2: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let testGroup0: any;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let parentPage: any;
+
+  // Mock Crowi instance with minimal required properties
+  const mockPageEvent = new MockPageEvent();
+  const crowiMock = {
+    events: {
+      page: mockPageEvent,
+    },
+  } as unknown as Crowi;
 
   beforeAll(async () => {
-    crowi = await getInstance();
-
-    User = mongoose.model('User');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
-    Page = mongoose.model('Page');
+    // Initialize configManager
+    const s2sMessagingServiceMock = mock<S2sMessagingService>();
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+    await configManager.loadConfigs();
+
+    // Initialize models with mocked Crowi using dynamic import
+    const pageModule = await import('./page');
+    const pageFactory = pageModule.default;
+    Page = pageFactory(crowiMock);
     PageQueryBuilder = Page.PageQueryBuilder;
 
+    const userModule = await import('./user/index');
+    const userFactory = userModule.default;
+    User = userFactory(null);
+
+    const userGroupModule = await import('./user-group');
+    UserGroup = userGroupModule.default;
+
+    const userGroupRelationModule = await import('./user-group-relation');
+    UserGroupRelation = userGroupRelationModule.default;
+
     await User.insertMany([
       {
         name: 'Anon 0',
@@ -278,7 +321,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page/child/without/parents');
@@ -293,7 +336,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(2);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page1');
@@ -311,7 +354,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page/child/without/parents');
@@ -326,7 +369,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page1/child1');
@@ -343,7 +386,7 @@ describe('Page', () => {
       // assert totalCount
       expect(result.length).toEqual(4);
       // assert paths
-      const pagePaths = result.map((page) => {
+      const pagePaths = result.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/page/child/without/parents');
@@ -362,7 +405,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -380,7 +423,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -398,7 +441,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -416,7 +459,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -438,7 +481,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(5);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -458,7 +501,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(3);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/groupacl');
@@ -476,7 +519,7 @@ describe('Page', () => {
       expect(pages.length).toEqual(2);
 
       // assert paths
-      const pagePaths = await pages.map((page) => {
+      const pagePaths = pages.map((page: { path: string }) => {
         return page.path;
       });
       expect(pagePaths).toContainEqual('/grant/public');

+ 4 - 3
apps/app/src/server/models/page.ts

@@ -11,6 +11,7 @@ import {
 } from '@growi/core/dist/utils/path-utils';
 import assert from 'assert';
 import escapeStringRegexp from 'escape-string-regexp';
+import type mongoose from 'mongoose';
 import type {
   AnyObject,
   Document,
@@ -18,7 +19,7 @@ import type {
   Model,
   Types,
 } from 'mongoose';
-import mongoose, { Schema } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import nodePath from 'path';
@@ -40,6 +41,7 @@ import {
   getPageSchema,
   populateDataToShowRevision,
 } from './obsolete-page';
+import { USER_FIELDS_EXCEPT_CONFIDENTIAL } from './user/conts';
 import type { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
@@ -928,7 +930,6 @@ schema.statics.findRecentUpdatedPages = async function (
 ): Promise<PaginatedPages> {
   const sortOpt = {};
   sortOpt[options.sort] = options.desc;
-  const User = mongoose.model('User') as any;
 
   if (path == null) {
     throw new Error('path is required.');
@@ -950,7 +951,7 @@ schema.statics.findRecentUpdatedPages = async function (
   }
 
   queryBuilder.addConditionToListWithDescendants(path, options);
-  queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+  queryBuilder.populateDataToList(USER_FIELDS_EXCEPT_CONFIDENTIAL);
   await queryBuilder.addViewerCondition(
     user,
     undefined,

+ 1 - 1
apps/app/test/integration/models/update-post.test.js → apps/app/src/server/models/update-post.spec.ts

@@ -1,4 +1,4 @@
-import UpdatePost from '../../../src/server/models/update-post';
+import UpdatePost from './update-post';
 
 describe('UpdatePost', () => {
   describe('.createPrefixesByPathPattern', () => {

+ 4 - 3
apps/app/src/server/models/user-group-relation.ts

@@ -1,5 +1,5 @@
 import { getIdForRef, isPopulated } from '@growi/core';
-import type { IUserGroupRelation } from '@growi/core/dist/interfaces';
+import type { IUser, IUserGroupRelation } from '@growi/core/dist/interfaces';
 import type { Document, Model } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
@@ -9,6 +9,7 @@ import loggerFactory from '~/utils/logger';
 
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
+import { UserStatus } from './user/conts';
 import type { UserGroupDocument } from './user-group';
 
 const logger = loggerFactory('growi:models:userGroupRelation');
@@ -207,7 +208,7 @@ schema.statics.countByGroupIdsAndUser = async function (
  * @memberof UserGroupRelation
  */
 schema.statics.findUserByNotRelatedGroup = function (userGroup, queryOptions) {
-  const User = mongoose.model('User') as any;
+  const User = mongoose.model<IUser>('User');
   let searchWord = new RegExp(`${queryOptions.searchWord}`);
   switch (queryOptions.searchType) {
     case 'forward':
@@ -231,7 +232,7 @@ schema.statics.findUserByNotRelatedGroup = function (userGroup, queryOptions) {
     });
     const query = {
       _id: { $nin: relatedUserIds },
-      status: User.STATUS_ACTIVE,
+      status: UserStatus.STATUS_ACTIVE,
       $or: searthField,
     };
 

Некоторые файлы не были показаны из-за большого количества измененных файлов