Quellcode durchsuchen

Enable TS `noImplicitAny` in `web` (#31636)

GitOrigin-RevId: 18881694770f2476c475f8fef4c6a2678a2a12fe
Anna Claire Fields vor 4 Monaten
Ursprung
Commit
6113c6c291
58 geänderte Dateien mit 1423 neuen und 281 gelöschten Zeilen
  1. 9 2
      libraries/mongo-utils/batchedUpdate.js
  2. 1 0
      libraries/mongo-utils/buildscript.txt
  3. 2 2
      libraries/mongo-utils/test-utils.js
  4. 3 0
      libraries/mongo-utils/tsconfig.json
  5. 1 0
      libraries/object-persistor/buildscript.txt
  6. 76 5
      libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js
  7. 61 13
      libraries/object-persistor/src/S3Persistor.js
  8. 3 0
      libraries/object-persistor/tsconfig.json
  9. 51 11
      libraries/redis-wrapper/index.js
  10. 1 1
      libraries/settings/Settings.js
  11. 110 0
      package-lock.json
  12. 3 1
      services/web/app/src/Features/Analytics/EmailChangeHelper.mjs
  13. 18 9
      services/web/app/src/Features/Authorization/PermissionsController.mjs
  14. 90 0
      services/web/app/src/Features/Chat/ChatApiHandler.mjs
  15. 67 2
      services/web/app/src/Features/Collaborators/CollaboratorsGetter.mjs
  16. 7 1
      services/web/app/src/Features/Docstore/DocstoreManager.mjs
  17. 18 0
      services/web/app/src/Features/FileStore/FileStoreController.mjs
  18. 124 14
      services/web/app/src/Features/History/HistoryController.mjs
  19. 38 30
      services/web/app/src/Features/History/HistoryRangesSupportMigration.mjs
  20. 4 0
      services/web/app/src/Features/History/HistoryRouter.mjs
  21. 2 0
      services/web/app/src/Features/Notifications/NotificationsBuilder.mjs
  22. 16 3
      services/web/app/src/Features/Project/ProjectListController.mjs
  23. 26 8
      services/web/app/src/Features/Spelling/SpellingController.mjs
  24. 4 2
      services/web/app/src/Features/Subscription/AiHelper.mjs
  25. 3 0
      services/web/app/src/Features/Subscription/Errors.mjs
  26. 33 1
      services/web/app/src/Features/Subscription/LimitationsManager.mjs
  27. 3 0
      services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs
  28. 15 13
      services/web/app/src/Features/Subscription/PlansLocator.mjs
  29. 30 1
      services/web/app/src/Features/Subscription/RecurlyClient.mjs
  30. 6 7
      services/web/app/src/Features/Subscription/RecurlyWrapper.mjs
  31. 147 42
      services/web/app/src/Features/Subscription/SubscriptionController.mjs
  32. 29 11
      services/web/app/src/Features/Subscription/SubscriptionHandler.mjs
  33. 26 2
      services/web/app/src/Features/Subscription/SubscriptionHelper.mjs
  34. 34 0
      services/web/app/src/Features/Subscription/TeamInvitesController.mjs
  35. 15 1
      services/web/app/src/Features/Uploads/FileTypeManager.mjs
  36. 33 14
      services/web/app/src/Features/Uploads/ProjectUploadController.mjs
  37. 18 18
      services/web/app/src/Features/User/UserGetter.mjs
  38. 94 35
      services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs
  39. 2 2
      services/web/app/src/infrastructure/AsyncLocalStorage.mjs
  40. 8 0
      services/web/app/src/infrastructure/GeoIpLookup.mjs
  41. 6 1
      services/web/app/src/infrastructure/HttpPermissionsPolicy.mjs
  42. 48 6
      services/web/app/src/infrastructure/Modules.mjs
  43. 5 1
      services/web/app/src/infrastructure/Validation.mjs
  44. 14 3
      services/web/app/src/infrastructure/rate-limiters/FeatureUsageRateLimiter.mjs
  45. 10 2
      services/web/app/src/infrastructure/rate-limiters/TokenUsageRateLimiter.mjs
  46. 3 1
      services/web/frontend/stylesheets/abstracts/mixins.scss
  47. 4 0
      services/web/package.json
  48. 4 0
      services/web/scripts/attach_dangling_comments_to_doc.mjs
  49. 16 0
      services/web/scripts/backfill_mixpanel_user_properties.mjs
  50. 13 0
      services/web/scripts/check_docs.mjs
  51. 3 0
      services/web/scripts/convert_track_changes_to_explicit_format.mjs
  52. 12 0
      services/web/scripts/delete_dangling_comments.mjs
  53. 6 0
      services/web/scripts/fix_comment_id.mjs
  54. 20 7
      services/web/scripts/fix_unconfirmed_secondaries_not_removed_v1.mjs
  55. 12 7
      services/web/scripts/recurly/generate_addon_prices.mjs
  56. 3 0
      services/web/scripts/recurly/set_manually_collected_subscriptions.mjs
  57. 10 2
      services/web/scripts/stripe/create_prices_from_csv.mjs
  58. 3 0
      services/web/tsconfig.backend.json

+ 9 - 2
libraries/mongo-utils/batchedUpdate.js

@@ -8,13 +8,20 @@ const READ_PREFERENCE_SECONDARY =
     : ReadPreference.secondaryPreferred.mode
 
 const ONE_MONTH_IN_MS = 1000 * 60 * 60 * 24 * 31
+/** @type {ObjectId | null} */
 let ID_EDGE_PAST
 const ID_EDGE_FUTURE = objectIdFromMs(Date.now() + 1000)
+/** @type {boolean} */
 let BATCH_DESCENDING
+/** @type {number} */
 let BATCH_SIZE
+/** @type {boolean} */
 let VERBOSE_LOGGING
+/** @type {ObjectId} */
 let BATCH_RANGE_START
+/** @type {ObjectId} */
 let BATCH_RANGE_END
+/** @type {number} */
 let BATCH_MAX_TIME_SPAN_IN_MS
 let BATCHED_UPDATE_RUNNING = false
 
@@ -55,7 +62,7 @@ function refreshGlobalOptionsForBatchedUpdate(options = {}) {
     if (BATCH_DESCENDING) {
       BATCH_RANGE_START = ID_EDGE_FUTURE
     } else {
-      BATCH_RANGE_START = ID_EDGE_PAST
+      BATCH_RANGE_START = /** @type {ObjectId} */ (ID_EDGE_PAST)
     }
   }
   BATCH_MAX_TIME_SPAN_IN_MS = parseInt(
@@ -66,7 +73,7 @@ function refreshGlobalOptionsForBatchedUpdate(options = {}) {
     BATCH_RANGE_END = objectIdFromInput(options.BATCH_RANGE_END)
   } else {
     if (BATCH_DESCENDING) {
-      BATCH_RANGE_END = ID_EDGE_PAST
+      BATCH_RANGE_END = /** @type {ObjectId} */ (ID_EDGE_PAST)
     } else {
       BATCH_RANGE_END = ID_EDGE_FUTURE
     }

+ 1 - 0
libraries/mongo-utils/buildscript.txt

@@ -7,3 +7,4 @@ mongo-utils
 --node-version=24.13.0
 --pipeline-owner=32
 --public-repo=False
+--tsconfig-no-implicit-any=True

+ 2 - 2
libraries/mongo-utils/test-utils.js

@@ -1,8 +1,8 @@
 // @ts-check
 
 /**
- * @import { MongoClient } from 'mongodb'
- * @import { MongoClient as LegacyMongoClient } from 'mongodb-legacy'
+ * @typedef {import('mongodb').MongoClient} MongoClient
+ * @typedef {import('mongodb-legacy').MongoClient} LegacyMongoClient
  */
 
 /**

+ 3 - 0
libraries/mongo-utils/tsconfig.json

@@ -1,4 +1,7 @@
 {
   "extends": "../../tsconfig.backend.json",
+  "compilerOptions": {
+    "noImplicitAny": true
+  },
   "include": ["**/*.js", "**/*.cjs", "**/*.ts"]
 }

+ 1 - 0
libraries/object-persistor/buildscript.txt

@@ -7,3 +7,4 @@ object-persistor
 --node-version=24.13.0
 --pipeline-owner=32
 --public-repo=False
+--tsconfig-no-implicit-any=True

+ 76 - 5
libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js

@@ -21,7 +21,7 @@ const hkdf = promisify(Crypto.hkdf)
 const AES256_KEY_LENGTH = 32
 
 /**
- * @typedef {Object} Settings
+ * @typedef {Object} EncryptionSettings
  * @property {boolean} automaticallyRotateDEKEncryption
  * @property {string} dataEncryptionKeyBucketName
  * @property {boolean} ignoreErrorsFromDEKReEncryption
@@ -29,6 +29,26 @@ const AES256_KEY_LENGTH = 32
  * @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
  */
 
+/**
+ * @typedef {Object} S3PersistorSettings
+ * @property {Object<string, import('@aws-sdk/client-s3').StorageClass>} [storageClass]
+ * @property {string} [key]
+ * @property {string} [secret]
+ * @property {string} [region]
+ * @property {string} [endpoint]
+ * @property {boolean} [pathStyle]
+ * @property {number} [maxRetries]
+ * @property {Object} [httpOptions]
+ * @property {string} [ca]
+ * @property {number} [signedUrlExpiryInMs]
+ * @property {number} [partSize]
+ * @property {Object<string, {auth_key: string, auth_secret: string}>} [bucketCreds]
+ */
+
+/**
+ * @typedef {S3PersistorSettings & EncryptionSettings} Settings
+ */
+
 /**
  * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
  */
@@ -105,10 +125,6 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
       })
   }
 
-  async ensureKeyEncryptionKeysLoaded() {
-    await this.#availableKeyEncryptionKeysPromise
-  }
-
   /**
    * @param {string} bucketName
    * @param {string} path
@@ -289,6 +305,13 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
     }
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {NodeJS.ReadableStream} sourceStream
+   * @param {import('./S3Persistor.js').StreamOptions} opts
+   * @return {Promise<void>}
+   */
   async sendStream(bucketName, path, sourceStream, opts = {}) {
     const ssecOptions =
       opts.ssecOptions ||
@@ -299,6 +322,13 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
     })
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {Object} opts
+   * @param {SSECOptions} [opts.ssecOptions]
+   * @return {Promise<NodeJS.ReadableStream>}
+   */
   async getObjectStream(bucketName, path, opts = {}) {
     const ssecOptions =
       opts.ssecOptions ||
@@ -309,6 +339,13 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
     })
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {Object} opts
+   * @param {SSECOptions} [opts.ssecOptions]
+   * @return {Promise<number>}
+   */
   async getObjectSize(bucketName, path, opts = {}) {
     const ssecOptions =
       opts.ssecOptions ||
@@ -316,6 +353,13 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
     return await super.getObjectSize(bucketName, path, { ...opts, ssecOptions })
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {Object} opts
+   * @param {SSECOptions} [opts.ssecOptions]
+   * @return {Promise<string | undefined>}
+   */
   async getObjectStorageClass(bucketName, path, opts = {}) {
     const ssecOptions =
       opts.ssecOptions ||
@@ -326,11 +370,23 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
     })
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {string} [continuationToken]
+   * @return {Promise<number>}
+   */
   async directorySize(bucketName, path, continuationToken) {
     // Note: Listing a bucket does not require SSE-C credentials.
     return await super.directorySize(bucketName, path, continuationToken)
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {string} [continuationToken]
+   * @return {Promise<void>}
+   */
   async deleteDirectory(bucketName, path, continuationToken) {
     // Let [Settings.pathToProjectFolder] validate the project path before deleting things.
     const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
@@ -344,12 +400,27 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
     }
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} path
+   * @param {Object} opts
+   * @return {Promise<string>}
+   */
   async getObjectMd5Hash(bucketName, path, opts = {}) {
     // The ETag in object metadata is not the MD5 content hash, skip the HEAD request.
     opts = { ...opts, etagIsNotMD5: true }
     return await super.getObjectMd5Hash(bucketName, path, opts)
   }
 
+  /**
+   * @param {string} bucketName
+   * @param {string} sourcePath
+   * @param {string} destinationPath
+   * @param {Object} opts
+   * @param {SSECOptions} [opts.ssecOptions]
+   * @param {SSECOptions} [opts.ssecSrcOptions]
+   * @return {Promise<void>}
+   */
   async copyObject(bucketName, sourcePath, destinationPath, opts = {}) {
     const ssecOptions =
       opts.ssecOptions ||

+ 61 - 13
libraries/object-persistor/src/S3Persistor.js

@@ -77,14 +77,53 @@ class SSECOptions {
   }
 }
 
+/**
+ * @typedef {import('@aws-sdk/client-s3').StorageClass} StorageClass
+ * @typedef {import('@aws-sdk/client-s3').PutObjectCommandInput} PutObjectCommandInput
+ * @typedef {import('@aws-sdk/client-s3').HeadObjectCommandOutput} HeadObjectCommandOutput
+ * @typedef {import('@aws-sdk/client-s3').GetObjectCommandOutput} GetObjectCommandOutput
+ * @typedef {import('@aws-sdk/client-s3').S3ClientConfig} S3ClientConfig
+ */
+
+/**
+ * @typedef {Object} StreamOptions
+ * @property {string} [contentType]
+ * @property {string} [contentEncoding]
+ * @property {number} [contentLength]
+ * @property {'*'} [ifNoneMatch]
+ * @property {SSECOptions} [ssecOptions]
+ */
+
+/**
+ * @typedef {Object} S3PersistorSettings
+ * @property {Object<string, StorageClass>} [storageClass]
+ * @property {string} [key]
+ * @property {string} [secret]
+ * @property {string} [region]
+ * @property {string} [endpoint]
+ * @property {boolean} [pathStyle]
+ * @property {number} [maxRetries]
+ * @property {Object} [httpOptions]
+ * @property {string} [ca]
+ * @property {number} [signedUrlExpiryInMs]
+ * @property {number} [partSize]
+ * @property {Object<string, {auth_key: string, auth_secret: string}>} [bucketCreds]
+ */
+
 class S3Persistor extends AbstractPersistor {
   /** @type {Map<string, S3Client>} */
   #clients = new Map()
+  /** @type {S3PersistorSettings} */
+  settings
 
+  /**
+   * @param {S3PersistorSettings} [settings]
+   */
   constructor(settings = {}) {
     super()
-
-    settings.storageClass = settings.storageClass || {}
+    settings.storageClass = /** @type {Object<string, StorageClass>} */ (
+      settings.storageClass || {}
+    )
     this.settings = settings
   }
 
@@ -102,12 +141,7 @@ class S3Persistor extends AbstractPersistor {
    * @param {string} bucketName
    * @param {string} key
    * @param {NodeJS.ReadableStream} readStream
-   * @param {Object} opts
-   * @param {string} [opts.contentType]
-   * @param {string} [opts.contentEncoding]
-   * @param {number} [opts.contentLength]
-   * @param {'*'} [opts.ifNoneMatch]
-   * @param {SSECOptions} [opts.ssecOptions]
+   * @param {StreamOptions} opts
    * @return {Promise<void>}
    */
   async sendStream(bucketName, key, readStream, opts = {}) {
@@ -128,8 +162,11 @@ class S3Persistor extends AbstractPersistor {
         Body: observer,
       }
 
-      if (this.settings.storageClass[bucketName]) {
-        uploadOptions.StorageClass = this.settings.storageClass[bucketName]
+      const storageClass = /** @type {Object<string, StorageClass>} */ (
+        this.settings.storageClass
+      )
+      if (storageClass[bucketName]) {
+        uploadOptions.StorageClass = storageClass[bucketName]
       }
 
       if ('sourceMd5' in opts) {
@@ -239,7 +276,9 @@ class S3Persistor extends AbstractPersistor {
    * @return {Promise<string>}
    */
   async getRedirectUrl(bucketName, key) {
-    const expiresSeconds = Math.round(this.settings.signedUrlExpiryInMs / 1000)
+    const expiresSeconds = Math.round(
+      /** @type {number} */ (this.settings.signedUrlExpiryInMs) / 1000
+    )
     try {
       return await getSignedUrl(
         this._getClientForBucket(bucketName),
@@ -311,6 +350,7 @@ class S3Persistor extends AbstractPersistor {
    */
   async #listDirectory(bucketName, key, continuationToken) {
     let response
+    /** @type {{ Bucket: string, Prefix: string, ContinuationToken?: string }} */
     const options = { Bucket: bucketName, Prefix: key }
     if (continuationToken) {
       options.ContinuationToken = continuationToken
@@ -501,6 +541,7 @@ class S3Persistor extends AbstractPersistor {
    */
   async directorySize(bucketName, key, continuationToken) {
     try {
+      /** @type {{ Bucket: string, Prefix: string, ContinuationToken?: string }} */
       const options = {
         Bucket: bucketName,
         Prefix: key,
@@ -621,7 +662,7 @@ class S3Persistor extends AbstractPersistor {
   }
 
   /**
-   * @param {Object} bucketCredentials
+   * @param {{auth_key: string, auth_secret: string} | undefined} bucketCredentials
    * @param {import('@aws-sdk/client-s3').S3ClientConfig} clientOptions
    * @return {import('@aws-sdk/client-s3').S3ClientConfig}
    * @private
@@ -637,7 +678,7 @@ class S3Persistor extends AbstractPersistor {
     } else if (this.settings.key) {
       options.credentials = {
         accessKeyId: this.settings.key,
-        secretAccessKey: this.settings.secret,
+        secretAccessKey: /** @type {string} */ (this.settings.secret),
       }
     } else {
       // Use the default credentials provider (process.env -> SSP -> ini -> IAM)
@@ -709,6 +750,9 @@ class S3Persistor extends AbstractPersistor {
   }
 
   // test-only
+  /**
+   * @param {string} bucketName
+   */
   _createBucket(bucketName) {
     return this._getClientForBucket(bucketName).send(
       new CreateBucketCommand({ Bucket: bucketName })
@@ -716,6 +760,10 @@ class S3Persistor extends AbstractPersistor {
   }
 
   // test-only
+  /**
+   * @param {string} bucketName
+   * @param {import('@aws-sdk/client-s3').PutObjectCommandInput} uploadOptions
+   */
   _upload(bucketName, uploadOptions) {
     return this._getClientForBucket(bucketName).send(
       new PutObjectCommand(uploadOptions)

+ 3 - 0
libraries/object-persistor/tsconfig.json

@@ -1,4 +1,7 @@
 {
   "extends": "../../tsconfig.backend.json",
+  "compilerOptions": {
+    "noImplicitAny": true
+  },
   "include": ["**/*.js", "**/*.cjs", "**/*.ts"]
 }

+ 51 - 11
libraries/redis-wrapper/index.js

@@ -4,6 +4,7 @@ const crypto = require('node:crypto')
 const os = require('node:os')
 const { promisify } = require('node:util')
 
+// @ts-ignore - ioredis types may not be available in all contexts
 const Redis = require('ioredis')
 
 const {
@@ -20,6 +21,9 @@ const PID = process.pid
 const RND = crypto.randomBytes(4).toString('hex')
 let COUNT = 0
 
+/**
+ * @param {any} opts
+ */
 function createClient(opts) {
   const standardOpts = Object.assign({}, opts)
   delete standardOpts.key_schema
@@ -42,6 +46,7 @@ function createClient(opts) {
     client = new Redis(standardOpts)
   }
   monkeyPatchIoRedisExec(client)
+  /** @param {any} callback */
   client.healthCheck = callback => {
     if (callback) {
       // callback based invocation
@@ -54,6 +59,9 @@ function createClient(opts) {
   return client
 }
 
+/**
+ * @param {any} client
+ */
 async function healthCheck(client) {
   // check the redis connection by storing and retrieving a unique key/value pair
   const uniqueToken = `host=${HOST}:pid=${PID}:random=${RND}:time=${Date.now()}:count=${COUNT++}`
@@ -71,6 +79,11 @@ async function healthCheck(client) {
   })
 }
 
+/**
+ * @param {any} client
+ * @param {any} uniqueToken
+ * @param {any} context
+ */
 async function runCheck(client, uniqueToken, context) {
   const healthCheckKey = `_redis-wrapper:healthCheckKey:{${uniqueToken}}`
   const healthCheckValue = `_redis-wrapper:healthCheckValue:{${uniqueToken}}`
@@ -79,9 +92,11 @@ async function runCheck(client, uniqueToken, context) {
   context.stage = 'write'
   const writeAck = await client
     .set(healthCheckKey, healthCheckValue, 'EX', 60)
-    .catch(err => {
-      throw new RedisHealthCheckWriteError('write errored', context, err)
-    })
+    .catch(
+      /** @param {any} err */ err => {
+        throw new RedisHealthCheckWriteError('write errored', context, err)
+      }
+    )
   if (writeAck !== 'OK') {
     context.writeAck = writeAck
     throw new RedisHealthCheckWriteError('write failed', context)
@@ -94,9 +109,15 @@ async function runCheck(client, uniqueToken, context) {
     .get(healthCheckKey)
     .del(healthCheckKey)
     .exec()
-    .catch(err => {
-      throw new RedisHealthCheckVerifyError('read/delete errored', context, err)
-    })
+    .catch(
+      /** @param {any} err */ err => {
+        throw new RedisHealthCheckVerifyError(
+          'read/delete errored',
+          context,
+          err
+        )
+      }
+    )
   if (roundTrippedHealthCheckValue !== healthCheckValue) {
     context.roundTrippedHealthCheckValue = roundTrippedHealthCheckValue
     throw new RedisHealthCheckVerifyError('read failed', context)
@@ -107,6 +128,10 @@ async function runCheck(client, uniqueToken, context) {
   }
 }
 
+/**
+ * @param {any} result
+ * @param {any} callback
+ */
 function unwrapMultiResult(result, callback) {
   // ioredis exec returns a results like:
   // [ [null, 42], [null, "foo"] ]
@@ -130,19 +155,30 @@ function unwrapMultiResult(result, callback) {
 }
 const unwrapMultiResultPromisified = promisify(unwrapMultiResult)
 
+/**
+ * @param {any} client
+ */
 function monkeyPatchIoRedisExec(client) {
   const _multi = client.multi
   client.multi = function () {
     const multi = _multi.apply(client, arguments)
     const _exec = multi.exec
+    /** @param {any} callback */
     multi.exec = callback => {
       if (callback) {
         // callback based invocation
-        _exec.call(multi, (error, result) => {
-          // The command can fail all-together due to syntax errors
-          if (error) return callback(error)
-          unwrapMultiResult(result, callback)
-        })
+        _exec.call(
+          multi,
+          /**
+           * @param {any} error
+           * @param {any} result
+           */
+          (error, result) => {
+            // The command can fail all-together due to syntax errors
+            if (error) return callback(error)
+            unwrapMultiResult(result, callback)
+          }
+        )
       } else {
         // Promise based invocation
         return _exec.call(multi).then(unwrapMultiResultPromisified)
@@ -152,7 +188,11 @@ function monkeyPatchIoRedisExec(client) {
   }
 }
 
+/**
+ * @param {{runner: any, timeout: any, context: any}} param0
+ */
 async function runWithTimeout({ runner, timeout, context }) {
+  /** @type {any} */
   let healthCheckDeadline
   await Promise.race([
     new Promise((resolve, reject) => {

+ 1 - 1
libraries/settings/Settings.js

@@ -45,7 +45,7 @@ if (!settingsExist) {
   console.warn("No settings or defaults found. I'm flying blind.")
 }
 
-module.exports = settings
+module.exports = /** @type {any} */ (settings)
 
 function pathIfExists(path) {
   if (path && fs.existsSync(path)) {

+ 110 - 0
package-lock.json

@@ -16409,6 +16409,13 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/async": {
+      "version": "3.2.25",
+      "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.25.tgz",
+      "integrity": "sha512-O6Th/DI18XjrL9TX8LO9F/g26qAz5vynmQqlXt/qLGrskvzCKXKc5/tATz3G2N6lM8eOf3M8/StB14FncAmocg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/aws-lambda": {
       "version": "8.10.160",
       "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz",
@@ -17176,6 +17183,95 @@
       "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
       "dev": true
     },
+    "node_modules/@types/sanitize-html": {
+      "version": "2.16.0",
+      "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
+      "integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "htmlparser2": "^8.0.0"
+      }
+    },
+    "node_modules/@types/sanitize-html/node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/@types/sanitize-html/node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/@types/sanitize-html/node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
+    "node_modules/@types/sanitize-html/node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/@types/sanitize-html/node_modules/htmlparser2": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+      "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+      "dev": true,
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1",
+        "entities": "^4.4.0"
+      }
+    },
     "node_modules/@types/semver": {
       "version": "7.5.0",
       "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
@@ -17305,6 +17401,16 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/utf-8-validate": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/@types/utf-8-validate/-/utf-8-validate-5.0.2.tgz",
+      "integrity": "sha512-ta7cOkEiNr0RGKARljNBaI7E1GBIr3VwS9RrSoQRmbdv1RVq7Q6VhjSGmQHYNt3nHn051qZBKKrpnw7cnEMDuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/uuid": {
       "version": "9.0.8",
       "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -53195,6 +53301,7 @@
         "@testing-library/react": "^16.3.0",
         "@testing-library/user-event": "^14.4.3",
         "@types/algoliasearch": "^3.34.11",
+        "@types/async": "^3.2.25",
         "@types/bootstrap": "^5.2.10",
         "@types/chai": "^4.3.0",
         "@types/dateformat": "^5.0.3",
@@ -53202,6 +53309,7 @@
         "@types/dom-speech-recognition": "^0.0.7",
         "@types/events": "^3.0.3",
         "@types/express": "^4.17.23",
+        "@types/minimist": "^1.2.5",
         "@types/mocha": "^9.1.0",
         "@types/mocha-each": "^2.0.4",
         "@types/node": "^24.5.2",
@@ -53212,7 +53320,9 @@
         "@types/react-linkify": "^1.0.4",
         "@types/react-syntax-highlighter": "^15.5.11",
         "@types/recurly__recurly-js": "^4.38.0",
+        "@types/sanitize-html": "^2.14.0",
         "@types/sinon-chai": "^3.2.12",
+        "@types/utf-8-validate": "^5.0.2",
         "@types/uuid": "^9.0.8",
         "@uppy/core": "^3.8.0",
         "@uppy/dashboard": "^3.7.1",

+ 3 - 1
services/web/app/src/Features/Analytics/EmailChangeHelper.mjs

@@ -50,7 +50,9 @@ async function registerEmailUpdate(userId, email, eventData = {}) {
  */
 async function makeEmailChangeEvent(userId, email, eventData) {
   const userEmails = await UserGetter.promises.getUserFullEmails(userId)
-  const emailData = userEmails.find(userEmail => userEmail.email === email)
+  const emailData = userEmails.find(
+    /** @param {any} userEmail */ userEmail => userEmail.email === email
+  )
 
   const filledEventData = fillMissingEventData(eventData, emailData)
 

+ 18 - 9
services/web/app/src/Features/Authorization/PermissionsController.mjs

@@ -25,10 +25,16 @@ const {
  * @returns {() => (req: Request, res: Response, next: NextFunction) => void} The middleware function that adds the `assertPermission` function to the request object.
  */
 function useCapabilities() {
+  /**
+   * @param {any} req
+   * @param {any} res
+   * @param {any} next
+   */
   const middleware = async function (req, res, next) {
     // attach the user's capabilities to the request object
     req.capabilitySet = new Set()
     // provide a function to assert that a capability is present
+    /** @param {any} capability */
     req.assertPermission = capability => {
       if (!req.capabilitySet.has(capability)) {
         throw new ForbiddenError(
@@ -52,7 +58,9 @@ function useCapabilities() {
 
       if (results.length > 0) {
         // get the combined group policy applying to the user
-        const groupPolicies = results.map(result => result.groupPolicy)
+        const groupPolicies = results.map(
+          /** @param {any} result */ result => result.groupPolicy
+        )
         const combinedGroupPolicy = combineGroupPolicies(groupPolicies)
         // attach the new capabilities to the request object
         for (const cap of getUserCapabilities(combinedGroupPolicy)) {
@@ -68,7 +76,7 @@ function useCapabilities() {
         }
       }
       next()
-    } catch (error) {
+    } catch (/** @type {any} */ error) {
       if (error instanceof UserNotFoundError) {
         // the user is logged in but doesn't exist in the database
         // this can happen if the user has just deleted their account
@@ -89,7 +97,10 @@ function useCapabilities() {
 function requirePermission(...requiredCapabilities) {
   if (
     requiredCapabilities.length === 0 ||
-    requiredCapabilities.some(capability => typeof capability !== 'string')
+    requiredCapabilities.some(
+      /** @param {any} capability */ capability =>
+        typeof capability !== 'string'
+    )
   ) {
     throw new Error('invalid required capabilities')
   }
@@ -102,16 +113,14 @@ function requirePermission(...requiredCapabilities) {
     if (!Features.hasFeature('saas')) {
       return next()
     }
-    if (!req.user && !req.oauth_user) {
+    const user = req.user || req.oauth_user
+    if (!user) {
       return next(new Error('no user'))
     }
     try {
-      await assertUserPermissions(
-        req.user || req.oauth_user,
-        requiredCapabilities
-      )
+      await assertUserPermissions(user, requiredCapabilities)
       next()
-    } catch (error) {
+    } catch (/** @type {any} */ error) {
       next(error)
     }
   }

+ 90 - 0
services/web/app/src/Features/Chat/ChatApiHandler.mjs

@@ -5,24 +5,44 @@ import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
 import settings from '@overleaf/settings'
 import { callbackify } from 'node:util'
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ */
 async function getThread(projectId, threadId) {
   return await fetchJson(chatApiUrl(`/project/${projectId}/thread/${threadId}`))
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ * @param {any} messageId
+ */
 async function getThreadMessage(projectId, threadId, messageId) {
   return await fetchJson(
     chatApiUrl(`/project/${projectId}/thread/${threadId}/messages/${messageId}`)
   )
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getThreads(projectId) {
   return await fetchJson(chatApiUrl(`/project/${projectId}/threads`))
 }
 
+/**
+ * @param {any} projectId
+ */
 async function destroyProject(projectId) {
   await fetchNothing(chatApiUrl(`/project/${projectId}`), { method: 'DELETE' })
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} userId
+ * @param {any} content
+ */
 async function sendGlobalMessage(projectId, userId, content) {
   const message = await fetchJson(
     chatApiUrl(`/project/${projectId}/messages`),
@@ -34,6 +54,11 @@ async function sendGlobalMessage(projectId, userId, content) {
   return message
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} limit
+ * @param {any} before
+ */
 async function getGlobalMessages(projectId, limit, before) {
   const url = chatApiUrl(`/project/${projectId}/messages`)
   if (limit != null) {
@@ -46,12 +71,22 @@ async function getGlobalMessages(projectId, limit, before) {
   return await fetchJson(url)
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} messageId
+ */
 async function getGlobalMessage(projectId, messageId) {
   return await fetchJson(
     chatApiUrl(`/project/${projectId}/messages/${messageId}`)
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ * @param {any} userId
+ * @param {any} content
+ */
 async function sendComment(projectId, threadId, userId, content) {
   const comment = await fetchJson(
     chatApiUrl(`/project/${projectId}/thread/${threadId}/messages`),
@@ -63,6 +98,11 @@ async function sendComment(projectId, threadId, userId, content) {
   return comment
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ * @param {any} userId
+ */
 async function resolveThread(projectId, threadId, userId) {
   await fetchNothing(
     chatApiUrl(`/project/${projectId}/thread/${threadId}/resolve`),
@@ -73,6 +113,10 @@ async function resolveThread(projectId, threadId, userId) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ */
 async function reopenThread(projectId, threadId) {
   await fetchNothing(
     chatApiUrl(`/project/${projectId}/thread/${threadId}/reopen`),
@@ -80,12 +124,23 @@ async function reopenThread(projectId, threadId) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ */
 async function deleteThread(projectId, threadId) {
   await fetchNothing(chatApiUrl(`/project/${projectId}/thread/${threadId}`), {
     method: 'DELETE',
   })
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ * @param {any} messageId
+ * @param {any} userId
+ * @param {any} content
+ */
 async function editMessage(projectId, threadId, messageId, userId, content) {
   await fetchNothing(
     chatApiUrl(
@@ -98,6 +153,12 @@ async function editMessage(projectId, threadId, messageId, userId, content) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} messageId
+ * @param {any} userId
+ * @param {any} content
+ */
 async function editGlobalMessage(projectId, messageId, userId, content) {
   await fetchNothing(
     chatApiUrl(`/project/${projectId}/messages/${messageId}/edit`),
@@ -108,6 +169,11 @@ async function editGlobalMessage(projectId, messageId, userId, content) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ * @param {any} messageId
+ */
 async function deleteMessage(projectId, threadId, messageId) {
   await fetchNothing(
     chatApiUrl(
@@ -117,6 +183,12 @@ async function deleteMessage(projectId, threadId, messageId) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threadId
+ * @param {any} userId
+ * @param {any} messageId
+ */
 async function deleteUserMessage(projectId, threadId, userId, messageId) {
   await fetchNothing(
     chatApiUrl(
@@ -126,6 +198,10 @@ async function deleteUserMessage(projectId, threadId, userId, messageId) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} messageId
+ */
 async function deleteGlobalMessage(projectId, messageId) {
   await fetchNothing(
     chatApiUrl(`/project/${projectId}/messages/${messageId}`),
@@ -133,6 +209,9 @@ async function deleteGlobalMessage(projectId, messageId) {
   )
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getResolvedThreadIds(projectId) {
   const body = await fetchJson(
     chatApiUrl(`/project/${projectId}/resolved-thread-ids`)
@@ -140,6 +219,10 @@ async function getResolvedThreadIds(projectId) {
   return body.resolvedThreadIds
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threads
+ */
 async function duplicateCommentThreads(projectId, threads) {
   return await fetchJson(
     chatApiUrl(`/project/${projectId}/duplicate-comment-threads`),
@@ -152,6 +235,10 @@ async function duplicateCommentThreads(projectId, threads) {
   )
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} threads
+ */
 async function generateThreadData(projectId, threads) {
   return await fetchJson(
     chatApiUrl(`/project/${projectId}/generate-thread-data`),
@@ -162,6 +249,9 @@ async function generateThreadData(projectId, threads) {
   )
 }
 
+/**
+ * @param {any} path
+ */
 function chatApiUrl(path) {
   return new URL(path, settings.apis.chat.internal_url)
 }

+ 67 - 2
services/web/app/src/Features/Collaborators/CollaboratorsGetter.mjs

@@ -212,6 +212,9 @@ class ProjectAccess {
   }
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getProjectAccess(projectId) {
   const project = await ProjectGetter.promises.getProject(projectId, {
     owner_ref: 1,
@@ -230,18 +233,33 @@ async function getProjectAccess(projectId) {
   return new ProjectAccess(project)
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getMemberIdsWithPrivilegeLevels(projectId) {
   return (await getProjectAccess(projectId)).allMembers()
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getMemberIds(projectId) {
   return (await getProjectAccess(projectId)).memberIds()
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getInvitedMemberIds(projectId) {
   return (await getProjectAccess(projectId)).invitedMemberIds()
 }
 
+/**
+ * @param {any} ownerId
+ * @param {any} collaboratorIds
+ * @param {any} readOnlyIds
+ * @param {any} reviewerIds
+ */
 async function getInvitedMembersWithPrivilegeLevelsFromFields(
   ownerId,
   collaboratorIds,
@@ -262,6 +280,10 @@ async function getInvitedMembersWithPrivilegeLevelsFromFields(
   return _loadMembers(members)
 }
 
+/**
+ * @param {any} userId
+ * @param {any} projectId
+ */
 async function getMemberIdPrivilegeLevel(userId, projectId) {
   // In future if the schema changes and getting all member ids is more expensive (multiple documents)
   // then optimise this.
@@ -271,14 +293,24 @@ async function getMemberIdPrivilegeLevel(userId, projectId) {
   return (await getProjectAccess(projectId)).privilegeLevelForUser(userId)
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getInvitedEditCollaboratorCount(projectId) {
   return (await getProjectAccess(projectId)).countInvitedEditCollaborators()
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getInvitedPendingEditorCount(projectId) {
   return (await getProjectAccess(projectId)).countInvitedPendingEditors()
 }
 
+/**
+ * @param {any} userId
+ * @param {any} projectId
+ */
 async function isUserInvitedMemberOfProject(userId, projectId) {
   if (!userId) {
     return false
@@ -286,6 +318,10 @@ async function isUserInvitedMemberOfProject(userId, projectId) {
   return (await getProjectAccess(projectId)).isUserInvitedMember(userId)
 }
 
+/**
+ * @param {any} userId
+ * @param {any} projectId
+ */
 async function isUserInvitedReadWriteMemberOfProject(userId, projectId) {
   if (!userId) {
     return false
@@ -295,6 +331,10 @@ async function isUserInvitedReadWriteMemberOfProject(userId, projectId) {
   )
 }
 
+/**
+ * @param {any} userId
+ * @param {any} projectId
+ */
 async function getPublicShareTokens(userId, projectId) {
   const memberInfo = await Project.findOne(
     {
@@ -335,6 +375,10 @@ async function getPublicShareTokens(userId, projectId) {
 // This function returns all the projects that a user currently has access to,
 // excluding projects where the user is listed in the token access fields when
 // token access has been disabled.
+/**
+ * @param {any} userId
+ * @param {any} fields
+ */
 async function getProjectsUserIsMemberOf(userId, fields) {
   // @ts-ignore
   const limit = pLimit(2)
@@ -368,6 +412,10 @@ async function getProjectsUserIsMemberOf(userId, fields) {
 // This function returns all the projects that a user is a member of, regardless of
 // the current state of the project, so it includes those projects where token access
 // has been disabled.
+/**
+ * @param {any} userId
+ * @param {any} fields
+ */
 async function dangerouslyGetAllProjectsUserIsMemberOf(userId, fields) {
   const readAndWrite = await Project.find(
     { collaberator_refs: userId },
@@ -385,6 +433,9 @@ async function dangerouslyGetAllProjectsUserIsMemberOf(userId, fields) {
   return { readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly }
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getAllInvitedMembers(projectId) {
   try {
     const projectAccess = await getProjectAccess(projectId)
@@ -395,6 +446,10 @@ async function getAllInvitedMembers(projectId) {
   }
 }
 
+/**
+ * @param {any} userId
+ * @param {any} projectId
+ */
 async function userIsTokenMember(userId, projectId) {
   userId = new ObjectId(userId.toString())
   projectId = new ObjectId(projectId.toString())
@@ -413,6 +468,10 @@ async function userIsTokenMember(userId, projectId) {
   return project != null
 }
 
+/**
+ * @param {any} userId
+ * @param {any} projectId
+ */
 async function userIsReadWriteTokenMember(userId, projectId) {
   userId = new ObjectId(userId.toString())
   projectId = new ObjectId(projectId.toString())
@@ -476,15 +535,20 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
   }
 
   for (const memberId of readOnlyIds || []) {
+    /** @type {ProjectMember} */
     const record = {
       id: memberId.toString(),
       privilegeLevel: PrivilegeLevels.READ_ONLY,
       source: Sources.INVITE,
     }
 
-    if (pendingEditorIds?.some(pe => memberId.equals(pe))) {
+    if (
+      pendingEditorIds?.some(/** @param {any} pe */ pe => memberId.equals(pe))
+    ) {
       record.pendingEditor = true
-    } else if (pendingReviewerIds?.some(pr => memberId.equals(pr))) {
+    } else if (
+      pendingReviewerIds?.some(/** @param {any} pr */ pr => memberId.equals(pr))
+    ) {
       record.pendingReviewer = true
     }
     members.push(record)
@@ -533,6 +597,7 @@ async function _loadMembers(members) {
     .map(member => {
       const user = users.get(member.id)
       if (!user) return null
+      /** @type {any} */
       const record = {
         user,
         privilegeLevel: member.privilegeLevel,

+ 7 - 1
services/web/app/src/Features/Docstore/DocstoreManager.mjs

@@ -238,7 +238,13 @@ async function isDocDeleted(projectId, docId) {
  * @param ranges
  * @return {Promise<{modified: *, rev: *}>}
  */
-async function updateDoc(projectId, docId, lines, version, ranges) {
+async function updateDoc(
+  projectId,
+  docId,
+  lines,
+  version,
+  /** @type {any} */ ranges
+) {
   const url = new URL(settings.apis.docstore.url)
   url.pathname = path.posix.join('project', projectId, 'doc', docId)
   try {

+ 18 - 0
services/web/app/src/Features/FileStore/FileStoreController.mjs

@@ -9,6 +9,10 @@ import HistoryManager from '../History/HistoryManager.mjs'
 import Errors from '../Errors/Errors.js'
 import { preparePlainTextResponse } from '../../infrastructure/Response.mjs'
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function getFile(req, res) {
   const projectId = req.params.Project_id
   const fileId = req.params.File_id
@@ -101,6 +105,10 @@ async function getFile(req, res) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function getFileHead(req, res) {
   const projectId = req.params.Project_id
   const fileId = req.params.File_id
@@ -159,6 +167,9 @@ async function getFileHead(req, res) {
   res.status(200).end()
 }
 
+/**
+ * @param {any} file
+ */
 function isHtml(file) {
   return (
     fileEndsWith(file, '.html') ||
@@ -167,6 +178,10 @@ function isHtml(file) {
   )
 }
 
+/**
+ * @param {any} file
+ * @param {any} ext
+ */
 function fileEndsWith(file, ext) {
   return (
     file.name != null &&
@@ -175,6 +190,9 @@ function fileEndsWith(file, ext) {
   )
 }
 
+/**
+ * @param {any} userAgent
+ */
 function isMobileSafari(userAgent) {
   return (
     userAgent &&

+ 124 - 14
services/web/app/src/Features/History/HistoryController.mjs

@@ -15,17 +15,24 @@ import {
 } from '@overleaf/fetch-utils'
 
 import settings from '@overleaf/settings'
+/** @type {any} */
 import SessionManager from '../Authentication/SessionManager.mjs'
 import UserGetter from '../User/UserGetter.mjs'
+/** @type {any} */
 import ProjectGetter from '../Project/ProjectGetter.mjs'
 import Errors from '../Errors/Errors.js'
+/** @type {any} */
 import HistoryManager from './HistoryManager.mjs'
+/** @type {any} */
 import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
+/** @type {any} */
 import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
+/** @type {any} */
 import RestoreManager from './RestoreManager.mjs'
 import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
 import Features from '../../infrastructure/Features.mjs'
 import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
+/** @type {any} */
 import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 
 // Number of seconds after which the browser should send a request to revalidate
@@ -38,10 +45,18 @@ const STALE_WHILE_REVALIDATE_SECONDS = 365 * 86400 // 1 year
 
 const MAX_HISTORY_ZIP_ATTEMPTS = 40
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function getBlob(req, res) {
   await requestBlob('GET', req, res)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function headBlob(req, res) {
   await requestBlob('HEAD', req, res)
 }
@@ -56,6 +71,11 @@ const requestBlobSchema = z.object({
   }),
 })
 
+/**
+ * @param {any} method
+ * @param {any} req
+ * @param {any} res
+ */
 async function requestBlob(method, req, res) {
   const { params } = parseReq(req, requestBlobSchema)
   const { project_id: projectId, hash } = params
@@ -76,7 +96,7 @@ async function requestBlob(method, req, res) {
         method,
         range
       ))
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     if (err instanceof Errors.NotFoundError) return res.status(404).end()
     throw err
   }
@@ -94,7 +114,7 @@ async function requestBlob(method, req, res) {
 
   try {
     await pipeline(stream, res)
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     // If the downstream request is cancelled, we get an
     // ERR_STREAM_PREMATURE_CLOSE, ignore these "errors".
     if (!isPrematureClose(err)) {
@@ -103,6 +123,10 @@ async function requestBlob(method, req, res) {
   }
 }
 
+/**
+ * @param {any} res
+ * @param {any} etag
+ */
 function setBlobCacheHeaders(res, etag) {
   // Blobs are immutable, so they can in principle be cached indefinitely. Here,
   // we ask the browser to cache them for some time, but then check back
@@ -115,6 +139,11 @@ function setBlobCacheHeaders(res, etag) {
   res.set('ETag', etag)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function proxyToHistoryApi(req, res, next) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const url = settings.apis.project_history.url + req.url
@@ -135,7 +164,7 @@ async function proxyToHistoryApi(req, res, next) {
 
   try {
     await pipeline(stream, res)
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     // If the downstream request is cancelled, we get an
     // ERR_STREAM_PREMATURE_CLOSE.
     if (!isPrematureClose(err)) {
@@ -144,6 +173,11 @@ async function proxyToHistoryApi(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function proxyToHistoryApiAndInjectUserDetails(req, res, next) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const url = settings.apis.project_history.url + req.url
@@ -155,6 +189,11 @@ async function proxyToHistoryApiAndInjectUserDetails(req, res, next) {
   res.json(data)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function resyncProjectHistory(req, res, next) {
   // increase timeout to 6 minutes
   res.setTimeout(6 * 60 * 1000)
@@ -173,7 +212,7 @@ async function resyncProjectHistory(req, res, next) {
       projectId,
       opts
     )
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     if (err instanceof Errors.ProjectHistoryDisabledError) {
       return res.sendStatus(404)
     } else {
@@ -184,6 +223,11 @@ async function resyncProjectHistory(req, res, next) {
   res.sendStatus(204)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function restoreFileFromV2(req, res, next) {
   const { project_id: projectId } = req.params
   const { version, pathname } = req.body
@@ -215,6 +259,11 @@ async function restoreFileFromV2(req, res, next) {
   })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function revertFile(req, res, next) {
   const { project_id: projectId } = req.params
   const { version, pathname } = req.body
@@ -247,6 +296,11 @@ async function revertFile(req, res, next) {
   })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function revertProject(req, res, next) {
   const { project_id: projectId } = req.params
   const { version } = req.body
@@ -273,6 +327,11 @@ async function revertProject(req, res, next) {
   res.json(reverted)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function getLabels(req, res, next) {
   const projectId = req.params.Project_id
 
@@ -284,6 +343,11 @@ async function getLabels(req, res, next) {
   res.json(labels)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function createLabel(req, res, next) {
   const projectId = req.params.Project_id
   const { comment, version } = req.body
@@ -301,6 +365,9 @@ async function createLabel(req, res, next) {
   res.json(label)
 }
 
+/**
+ * @param {any} label
+ */
 async function _enrichLabel(label) {
   const newLabel = Object.assign({}, label)
   if (!label.user_id) {
@@ -317,11 +384,16 @@ async function _enrichLabel(label) {
   return newLabel
 }
 
+/**
+ * @param {any} labels
+ */
 async function _enrichLabels(labels) {
   if (!labels || !labels.length) {
     return []
   }
-  const uniqueUsers = new Set(labels.map(label => label.user_id))
+  const uniqueUsers = new Set(
+    labels.map(/** @param {any} label */ label => label.user_id)
+  )
 
   // For backwards compatibility, and for anonymously created labels in SP
   // expect missing user_id fields
@@ -336,15 +408,22 @@ async function _enrichLabels(labels) {
     last_name: 1,
     email: 1,
   })
-  const users = new Map(rawUsers.map(user => [String(user._id), user]))
+  const users = new Map(
+    rawUsers.map(/** @param {any} user */ user => [String(user._id), user])
+  )
 
-  labels.forEach(label => {
-    const user = users.get(label.user_id)
-    label.user_display_name = _displayNameForUser(user)
-  })
+  labels.forEach(
+    /** @param {any} label */ label => {
+      const user = users.get(label.user_id)
+      label.user_display_name = _displayNameForUser(user)
+    }
+  )
   return labels
 }
 
+/**
+ * @param {any} user
+ */
 function _displayNameForUser(user) {
   if (user == null) {
     return 'Anonymous'
@@ -365,6 +444,11 @@ function _displayNameForUser(user) {
   return name
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function deleteLabel(req, res, next) {
   const { Project_id: projectId, label_id: labelId } = req.params
   const userId = SessionManager.getLoggedInUserId(req.session)
@@ -393,11 +477,17 @@ const downloadZipOfVersionSchema = z.object({
   }),
 })
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function downloadZipOfVersion(req, res, next) {
   const { params } = parseReq(req, downloadZipOfVersionSchema)
   const { project_id: projectId, version } = params
   const userId = SessionManager.getLoggedInUserId(req.session)
 
+  /** @type {any} */
   const project = await ProjectDetailsHandler.promises.getDetails(projectId)
   const v1Id =
     project.overleaf && project.overleaf.history && project.overleaf.history.id
@@ -430,6 +520,13 @@ async function downloadZipOfVersion(req, res, next) {
   )
 }
 
+/**
+ * @param {any} v1ProjectId
+ * @param {any} version
+ * @param {any} name
+ * @param {any} req
+ * @param {any} res
+ */
 async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
   if (req.destroyed) {
     // client has disconnected -- skip project history api call and download
@@ -447,7 +544,7 @@ async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
     let stream
     try {
       stream = await fetchStream(url, { basicAuth })
-    } catch (err) {
+    } catch (/** @type {any} */ err) {
       if (err instanceof RequestFailedError && err.response.status === 404) {
         return res.sendStatus(404)
       } else {
@@ -459,7 +556,7 @@ async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
 
     try {
       await pipeline(stream, res)
-    } catch (err) {
+    } catch (/** @type {any} */ err) {
       // If the downstream request is cancelled, we get an
       // ERR_STREAM_PREMATURE_CLOSE.
       if (!isPrematureClose(err)) {
@@ -472,7 +569,7 @@ async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
   let body
   try {
     body = await fetchJson(url, { method: 'POST', basicAuth })
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     if (err instanceof RequestFailedError && err.response.status === 404) {
       throw new Errors.NotFoundError('zip not found')
     } else {
@@ -513,7 +610,7 @@ async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
       const stream = await fetchStream(body.zipUrl)
       prepareZipAttachment(res, `${name}.zip`)
       await pipeline(stream, res)
-    } catch (err) {
+    } catch (/** @type {any} */ err) {
       if (attempt > MAX_HISTORY_ZIP_ATTEMPTS) {
         throw err
       }
@@ -545,6 +642,11 @@ const getLatestHistorySchema = z.object({
   }),
 })
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function getLatestHistory(req, res, next) {
   const { params } = parseReq(req, getLatestHistorySchema)
   const projectId = params.project_id
@@ -562,6 +664,11 @@ const getChangesSchema = z.object({
   }),
 })
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function getChanges(req, res, next) {
   const { params, query } = parseReq(req, getChangesSchema)
   const projectId = params.project_id
@@ -598,6 +705,9 @@ async function getChanges(req, res, next) {
   }
 }
 
+/**
+ * @param {any} err
+ */
 function isPrematureClose(err) {
   return (
     err instanceof Error &&

+ 38 - 30
services/web/app/src/Features/History/HistoryRangesSupportMigration.mjs

@@ -71,6 +71,9 @@ async function migrateProjects(opts = {}) {
     .sort({ _id: -1 })
 
   let terminating = false
+  /**
+   * @param {any} signal
+   */
   const handleSignal = signal => {
     logger.info({ signal }, 'History ranges support migration received signal')
     terminating = true
@@ -78,6 +81,7 @@ async function migrateProjects(opts = {}) {
   process.on('SIGINT', handleSignal)
   process.on('SIGTERM', handleSignal)
 
+  /** @type {{ quick: number; skipped: number; resync: number; total: number; [key: string]: number }} */
   const projectsProcessed = {
     quick: 0,
     skipped: 0,
@@ -120,35 +124,39 @@ async function migrateProjects(opts = {}) {
     }
 
     const job = processProject(projectId, direction, quickOnly)
-      .then(info => {
-        jobsByProjectId.delete(projectId)
-        projectsProcessed[info.migrationType] += 1
-        projectsProcessed.total += 1
-        logger.debug(
-          {
-            projectId,
-            direction,
-            projectsProcessed,
-            errors,
-            ...info,
-          },
-          'History ranges support migration'
-        )
-        if (projectsProcessed.total % 10000 === 0) {
-          logger.info(
-            { projectsProcessed, errors, lastProjectId: projectId },
-            'History ranges support migration progress'
+      .then(
+        /** @param {any} info */ info => {
+          jobsByProjectId.delete(projectId)
+          projectsProcessed[info.migrationType] += 1
+          projectsProcessed.total += 1
+          logger.debug(
+            {
+              projectId,
+              direction,
+              projectsProcessed,
+              errors,
+              ...info,
+            },
+            'History ranges support migration'
           )
+          if (projectsProcessed.total % 10000 === 0) {
+            logger.info(
+              { projectsProcessed, errors, lastProjectId: projectId },
+              'History ranges support migration progress'
+            )
+          }
         }
-      })
-      .catch(err => {
-        jobsByProjectId.delete(projectId)
-        errors += 1
-        logger.error(
-          { err, projectId, direction, projectsProcessed, errors },
-          'Failed to migrate history ranges support'
-        )
-      })
+      )
+      .catch(
+        /** @param {any} err */ err => {
+          jobsByProjectId.delete(projectId)
+          errors += 1
+          logger.error(
+            { err, projectId, direction, projectsProcessed, errors },
+            'Failed to migrate history ranges support'
+          )
+        }
+      )
 
     jobsByProjectId.set(projectId, job)
   }
@@ -198,7 +206,7 @@ async function quickMigration(projectId, direction = 'forwards') {
   try {
     projectHasRanges =
       await DocstoreManager.promises.projectHasRanges(projectId)
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     // Docstore request probably timed out. Assume the project has ranges
     logger.warn(
       { err, projectId },
@@ -216,7 +224,7 @@ async function quickMigration(projectId, direction = 'forwards') {
       projectId,
       direction === 'forwards'
     )
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     await DocumentUpdaterHandler.promises.unblockProject(projectId)
     await hardResyncProject(projectId)
     throw err
@@ -225,7 +233,7 @@ async function quickMigration(projectId, direction = 'forwards') {
   let wasBlocked
   try {
     wasBlocked = await DocumentUpdaterHandler.promises.unblockProject(projectId)
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     await hardResyncProject(projectId)
     throw err
   }

+ 4 - 0
services/web/app/src/Features/History/HistoryRouter.mjs

@@ -23,6 +23,10 @@ const rateLimiters = {
   }),
 }
 
+/**
+ * @param {any} webRouter
+ * @param {any} privateApiRouter
+ */
 function apply(webRouter, privateApiRouter) {
   // Blobs
 

+ 2 - 0
services/web/app/src/Features/Notifications/NotificationsBuilder.mjs

@@ -260,6 +260,7 @@ function oldDebugProjects(userId) {
   }
 }
 
+/** @type {Record<string, any>} */
 const NotificationsBuilder = {
   // Note: notification keys should be url-safe
   dropboxUnlinkedDueToLapsedReconfirmation(userId) {
@@ -291,6 +292,7 @@ const NotificationsBuilder = {
   },
 }
 
+/** @type {Record<string, any>} */
 NotificationsBuilder.promises = {
   dropboxUnlinkedDueToLapsedReconfirmation,
   redundantPersonalSubscription,

+ 16 - 3
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -42,8 +42,8 @@ import UserSettingsHelper from './UserSettingsHelper.mjs'
 
 /**
  * @param {Affiliation} affiliation
- * @param session
- * @param linkedInstitutionIds
+ * @param {any} session
+ * @param {string[]} linkedInstitutionIds
  * @returns {boolean}
  * @private
  */
@@ -99,6 +99,9 @@ const _buildPortalTemplatesList = affiliations => {
   return portalTemplates
 }
 
+/**
+ * @param {any} req
+ */
 function cleanupSession(req) {
   // cleanup redirects at the end of the redirect chain
   delete req.session.postCheckoutRedirect
@@ -124,10 +127,12 @@ async function projectListPage(req, res, next) {
   // - object - the subscription data object
   let usersBestSubscription
   let usersIndividualSubscription
+  /** @type {any[]} */
   let usersGroupSubscriptions = []
   let usersManagedGroupSubscriptions = []
   let survey
   let userIsMemberOfGroupSubscription = false
+  /** @type {any[]} */
   let groupSubscriptionsPendingEnrollment = []
 
   const isSaas = Features.hasFeature('saas')
@@ -153,7 +158,8 @@ async function projectListPage(req, res, next) {
       if (groupsWithEmails && groupsWithEmails.length > 0) {
         if (
           groupsWithEmails.some(
-            ({ subscription }) => subscription.managedUsersEnabled
+            (/** @type {any} */ { subscription }) =>
+              subscription.managedUsersEnabled
           )
         ) {
           return res.redirect('/domain-capture')
@@ -338,6 +344,7 @@ async function projectListPage(req, res, next) {
     reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
     const samlSession = req.session.saml
     // Notification: SSO Available
+    /** @type {string[]} */
     const linkedInstitutionIds = []
     userEmails.forEach(email => {
       if (email.samlProviderId) {
@@ -906,6 +913,9 @@ function _hasActiveFilter(filters) {
   )
 }
 
+/**
+ * @param {any} user
+ */
 // todo: quota clean-up: rename function and vars
 async function _userHasAIAssist(user) {
   let hasPremiumAiFeatures
@@ -937,6 +947,9 @@ async function _userHasAIAssist(user) {
 // Determines if user is able to enable AI assist
 // based on their permissions and settings
 // It does NOT determine if the user has AI Assist enabled
+/**
+ * @param {any} user
+ */
 async function _canUseAIAssist(user) {
   // Check if the assistant has been manually disabled by the user
   // post https://github.com/overleaf/internal/pull/31273 we can rely on user.aiFeatures being populated

+ 26 - 8
services/web/app/src/Features/Spelling/SpellingController.mjs

@@ -17,23 +17,41 @@ const unlearnSchema = z.object({
 })
 
 export default {
+  /**
+   * @param {any} req
+   * @param {any} res
+   * @param {any} next
+   */
   learn(req, res, next) {
     const { body } = parseReq(req, learnSchema)
     const { word } = body
     const userId = SessionManager.getLoggedInUserId(req.session)
-    LearnedWordsManager.learnWord(userId, word, err => {
-      if (err) return next(err)
-      res.sendStatus(204)
-    })
+    LearnedWordsManager.learnWord(
+      userId,
+      word,
+      /** @param {any} err */ err => {
+        if (err) return next(err)
+        res.sendStatus(204)
+      }
+    )
   },
 
+  /**
+   * @param {any} req
+   * @param {any} res
+   * @param {any} next
+   */
   unlearn(req, res, next) {
     const { body } = parseReq(req, unlearnSchema)
     const { word } = body
     const userId = SessionManager.getLoggedInUserId(req.session)
-    LearnedWordsManager.unlearnWord(userId, word, err => {
-      if (err) return next(err)
-      res.sendStatus(204)
-    })
+    LearnedWordsManager.unlearnWord(
+      userId,
+      word,
+      /** @param {any} err */ err => {
+        if (err) return next(err)
+        res.sendStatus(204)
+      }
+    )
   },
 }

+ 4 - 2
services/web/app/src/Features/Subscription/AiHelper.mjs

@@ -22,7 +22,7 @@ export function isStandaloneAiAddOnPlanCode(planCode) {
 /**
  * Returns whether subscription change will have have the ai bundle once the change is processed
  *
- * @param {Object} subscriptionChange The subscription change object coming from payment provider
+ * @param {Record<string, any>} subscriptionChange The subscription change object coming from payment provider
  * type should be PaymentProviderSubscriptionChange but if imported here, it creates a circular dependency
  * TODO: fix this when moved to es modules
  *
@@ -31,7 +31,9 @@ export function isStandaloneAiAddOnPlanCode(planCode) {
 export function subscriptionChangeIsAiAssistUpgrade(subscriptionChange) {
   return Boolean(
     isStandaloneAiAddOnPlanCode(subscriptionChange.nextPlanCode) ||
-    subscriptionChange.nextAddOns?.some(addOn => addOn.code === AI_ADD_ON_CODE)
+    subscriptionChange.nextAddOns?.some(
+      /** @param {any} addOn */ addOn => addOn.code === AI_ADD_ON_CODE
+    )
   )
 }
 

+ 3 - 0
services/web/app/src/Features/Subscription/Errors.mjs

@@ -5,6 +5,9 @@ import Errors from '../Errors/Errors.js'
 import OError from '@overleaf/o-error'
 
 export class RecurlyTransactionError extends Errors.BackwardCompatibleError {
+  /**
+   * @param {any} options
+   */
   constructor(options) {
     super({
       message: 'Unknown transaction error',

+ 33 - 1
services/web/app/src/Features/Subscription/LimitationsManager.mjs

@@ -11,6 +11,9 @@ import CollaboratorsInvitesGetter from '../Collaborators/CollaboratorsInviteGett
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
 import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
 
+/**
+ * @param {any} projectId
+ */
 async function allowedNumberOfCollaboratorsInProject(projectId) {
   const project = await ProjectGetter.promises.getProject(projectId, {
     owner_ref: true,
@@ -18,15 +21,21 @@ async function allowedNumberOfCollaboratorsInProject(projectId) {
   return await allowedNumberOfCollaboratorsForUser(project.owner_ref)
 }
 
+/**
+ * @param {any} userId
+ */
 async function allowedNumberOfCollaboratorsForUser(userId) {
   const user = await UserGetter.promises.getUser(userId, { features: 1 })
-  if (user.features && user.features.collaborators) {
+  if (user?.features?.collaborators) {
     return user.features.collaborators
   } else {
     return Settings.defaultFeatures.collaborators
   }
 }
 
+/**
+ * @param {any} projectId
+ */
 async function canAcceptEditCollaboratorInvite(projectId) {
   const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId)
   if (allowedNumber < 0) {
@@ -39,6 +48,10 @@ async function canAcceptEditCollaboratorInvite(projectId) {
   return currentEditors + 1 <= allowedNumber
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} numberOfNewEditCollaborators
+ */
 async function canAddXEditCollaborators(
   projectId,
   numberOfNewEditCollaborators
@@ -106,6 +119,9 @@ async function canChangeCollaboratorPrivilegeLevel(
   return slotsTaken + inviteCount < allowedNumber
 }
 
+/**
+ * @param {any} user
+ */
 async function hasPaidSubscription(user) {
   const { hasSubscription, subscription } = await userHasSubscription(user)
   const { isMember } = await userIsMemberOfGroupSubscription(user)
@@ -115,11 +131,17 @@ async function hasPaidSubscription(user) {
   }
 }
 
+/**
+ * @param {any} user
+ */
 // alias for backward-compatibility with modules. Use `haspaidsubscription` instead
 async function userHasSubscriptionOrIsGroupMember(user) {
   return await hasPaidSubscription(user)
 }
 
+/**
+ * @param {any} user
+ */
 async function userHasSubscription(user) {
   const subscription = await SubscriptionLocator.promises.getUsersSubscription(
     user._id
@@ -140,12 +162,18 @@ async function userHasSubscription(user) {
   }
 }
 
+/**
+ * @param {any} user
+ */
 async function userIsMemberOfGroupSubscription(user) {
   const subscriptions =
     (await SubscriptionLocator.promises.getMemberSubscriptions(user._id)) || []
   return { isMember: subscriptions.length > 0, subscriptions }
 }
 
+/**
+ * @param {any} subscription
+ */
 function teamHasReachedMemberLimit(subscription) {
   const currentTotal =
     (subscription.member_ids || []).length +
@@ -155,6 +183,10 @@ function teamHasReachedMemberLimit(subscription) {
   return currentTotal >= subscription.membersLimit
 }
 
+/**
+ * @param {any} subscriptionId
+ * @param {any} callback
+ */
 async function hasGroupMembersLimitReached(subscriptionId, callback) {
   const subscription =
     await SubscriptionLocator.promises.getSubscription(subscriptionId)

+ 3 - 0
services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs

@@ -601,6 +601,9 @@ export class PaymentProviderSubscriptionChange {
     this.total = this.subtotal + this.tax
   }
 
+  /**
+   * @param {any} addOnCode
+   */
   getAddOn(addOnCode) {
     return this.nextAddOns.find(addOn => addOn.code === addOnCode)
   }

+ 15 - 13
services/web/app/src/Features/Subscription/PlansLocator.mjs

@@ -14,20 +14,22 @@ import logger from '@overleaf/logger'
  */
 
 function ensurePlansAreSetupCorrectly() {
-  Settings.plans.forEach(plan => {
-    if (typeof plan.price_in_cents !== 'number') {
-      logger.fatal({ plan }, 'missing price on plan')
-      process.exit(1)
+  Settings.plans.forEach(
+    /** @param {any} plan */ plan => {
+      if (typeof plan.price_in_cents !== 'number') {
+        logger.fatal({ plan }, 'missing price on plan')
+        process.exit(1)
+      }
+      if (plan.price) {
+        logger.fatal({ plan }, 'unclear price attribute on plan')
+        process.exit(1)
+      }
+      if (plan.price_in_unit) {
+        logger.fatal({ plan }, 'deprecated price_in_unit attribute on plan')
+        process.exit(1)
+      }
     }
-    if (plan.price) {
-      logger.fatal({ plan }, 'unclear price attribute on plan')
-      process.exit(1)
-    }
-    if (plan.price_in_unit) {
-      logger.fatal({ plan }, 'deprecated price_in_unit attribute on plan')
-      process.exit(1)
-    }
-  })
+  )
 }
 
 /**

+ 30 - 1
services/web/app/src/Features/Subscription/RecurlyClient.mjs

@@ -36,7 +36,7 @@ import _ from 'lodash'
 
 class RecurlyClientWithErrorHandling extends recurly.Client {
   /**
-   * @param {import('recurly/lib/recurly/Http').Response} response
+   * @param {any} response
    * @return {Error | null}
    * @private
    */
@@ -78,6 +78,9 @@ async function getAccountForUserId(userId) {
   }
 }
 
+/**
+ * @param {any} userId
+ */
 async function createAccountForUserId(userId) {
   const user = await UserGetter.promises.getUser(userId, {
     _id: 1,
@@ -345,6 +348,7 @@ async function _reapplyPendingChangeAfterImmediateUpdate(
   )
 
   // Merge: start with pending add-ons, add any new add-ons from immediate update
+  /** @type {Record<string, any>} */
   const mergedAddOns = { ...preUpdatePendingAddOns }
   for (const [code, details] of Object.entries(postUpdateAddOns)) {
     // include any add-ons that were added via immediate update just now
@@ -446,20 +450,32 @@ async function previewSubscriptionChange(changeRequest) {
   }
 }
 
+/**
+ * @param {any} subscriptionId
+ */
 async function removeSubscriptionChange(subscriptionId) {
   const removed = await client.removeSubscriptionChange(subscriptionId)
   logger.debug({ subscriptionId }, 'removed pending subscription change')
   return removed
 }
 
+/**
+ * @param {any} subscriptionUuid
+ */
 async function removeSubscriptionChangeByUuid(subscriptionUuid) {
   return await removeSubscriptionChange('uuid-' + subscriptionUuid)
 }
 
+/**
+ * @param {any} subscriptionUuid
+ */
 async function reactivateSubscriptionByUuid(subscriptionUuid) {
   return await client.reactivateSubscription('uuid-' + subscriptionUuid)
 }
 
+/**
+ * @param {any} subscriptionUuid
+ */
 async function cancelSubscriptionByUuid(subscriptionUuid) {
   try {
     return await client.cancelSubscription('uuid-' + subscriptionUuid)
@@ -493,12 +509,19 @@ async function cancelSubscriptionByUuid(subscriptionUuid) {
   }
 }
 
+/**
+ * @param {any} subscriptionUuid
+ * @param {any} pauseCycles
+ */
 async function pauseSubscriptionByUuid(subscriptionUuid, pauseCycles) {
   return await client.pauseSubscription('uuid-' + subscriptionUuid, {
     remainingPauseCycles: pauseCycles,
   })
 }
 
+/**
+ * @param {any} subscriptionUuid
+ */
 async function resumeSubscriptionByUuid(subscriptionUuid) {
   return await client.resumeSubscription('uuid-' + subscriptionUuid)
 }
@@ -552,6 +575,9 @@ async function getPlan(planCode) {
   return planFromApi(plan)
 }
 
+/**
+ * @param {any} subscription
+ */
 function subscriptionIsCanceledOrExpired(subscription) {
   const state = subscription?.recurlyStatus?.state
   return state === 'canceled' || state === 'expired'
@@ -884,6 +910,9 @@ function subscriptionUpdateRequestToApi(updateRequest) {
   return requestBody
 }
 
+/**
+ * @param {any} subscriptionUuid
+ */
 async function terminateSubscriptionByUuid(subscriptionUuid) {
   const subscription = await client.terminateSubscription(
     'uuid-' + subscriptionUuid,

+ 6 - 7
services/web/app/src/Features/Subscription/RecurlyWrapper.mjs

@@ -14,8 +14,8 @@ import RecurlyMetrics from './RecurlyMetrics.mjs'
 /**
  * Updates the email address of a Recurly account
  *
- * @param userId
- * @param newAccountEmail - the new email address to set for the Recurly account
+ * @param {any} userId
+ * @param {any} newAccountEmail - the new email address to set for the Recurly account
  */
 async function updateAccountEmailAddress(userId, newAccountEmail) {
   const data = {
@@ -903,11 +903,10 @@ const RecurlyWrapper = {
   getSubscription: callbackify(promises.getSubscription),
   getSubscriptions: callbackify(promises.getSubscriptions),
   updateAccountEmailAddress: callbackify(promises.updateAccountEmailAddress),
-}
-
-RecurlyWrapper.promises = {
-  ...promises,
-  updateAccountEmailAddress,
+  promises: {
+    ...promises,
+    updateAccountEmailAddress,
+  },
 }
 
 export default RecurlyWrapper

+ 147 - 42
services/web/app/src/Features/Subscription/SubscriptionController.mjs

@@ -54,7 +54,7 @@ const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
 
 /**
  * Check if a Stripe subscription is currently paused
- * @param {Object} subscription - The subscription object
+ * @param {Record<string, any>} subscription - The subscription object
  * @returns {Promise<boolean>}
  */
 async function _checkStripeSubscriptionPauseStatus(subscription) {
@@ -78,7 +78,7 @@ async function _checkStripeSubscriptionPauseStatus(subscription) {
 
 /**
  * Check if a Recurly subscription is currently paused
- * @param {Object} subscription - The subscription object
+ * @param {Record<string, any>} subscription - The subscription object
  * @returns {Promise<boolean>}
  */
 async function _checkRecurlySubscriptionPauseStatus(subscription) {
@@ -102,7 +102,7 @@ async function _checkRecurlySubscriptionPauseStatus(subscription) {
 }
 
 /** Check if a user's subscription is manual or custom
- * @param {Object} user - The user object
+ * @param {Record<string, any>} user - The user object
  * @returns {Promise<boolean>}
  */
 async function _isManualOrCustomSubscription(user) {
@@ -120,7 +120,7 @@ async function _isManualOrCustomSubscription(user) {
 
 /**
  * Check if a user's subscription is currently paused
- * @param {Object} user - The user object
+ * @param {Record<string, any>} user - The user object
  * @returns {Promise<{isPaused: boolean, redirectPath?: string}>}
  */
 async function checkSubscriptionPauseStatus(user) {
@@ -176,6 +176,10 @@ function formatGroupPlansDataForDash() {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function userSubscriptionPage(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
@@ -242,6 +246,7 @@ async function userSubscriptionPage(req, res) {
   try {
     const managedGroups = await async.filter(
       managedGroupSubscriptions || [],
+      /** @param {any} subscription */
       async subscription => {
         const managedUsersResults = await Modules.promises.hooks.fire(
           'hasManagedUsersFeature',
@@ -261,8 +266,8 @@ async function userSubscriptionPage(req, res) {
         )
       }
     )
-    groupSettingsEnabledFor = managedGroups.map(subscription =>
-      subscription._id.toString()
+    groupSettingsEnabledFor = managedGroups.map(
+      (/** @type {any} */ subscription) => subscription._id.toString()
     )
   } catch (error) {
     logger.error(
@@ -275,7 +280,7 @@ async function userSubscriptionPage(req, res) {
   try {
     const managedGroups = await async.filter(
       managedGroupSubscriptions || [],
-      async subscription => {
+      async (/** @type {any} */ subscription) => {
         const managedUsersResults = await Modules.promises.hooks.fire(
           'hasManagedUsersFeatureOnNonProfessionalPlan',
           subscription
@@ -296,8 +301,8 @@ async function userSubscriptionPage(req, res) {
         )
       }
     )
-    groupSettingsAdvertisedFor = managedGroups.map(subscription =>
-      subscription._id.toString()
+    groupSettingsAdvertisedFor = managedGroups.map(
+      (/** @type {any} */ subscription) => subscription._id.toString()
     )
   } catch (error) {
     logger.error(
@@ -339,6 +344,10 @@ async function userSubscriptionPage(req, res) {
   res.render('subscriptions/dashboard-react', data)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function successfulSubscription(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   if (!user) {
@@ -382,6 +391,11 @@ const pauseSubscriptionSchema = z.object({
   }),
 })
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function pauseSubscription(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const { params } = parseReq(req, pauseSubscriptionSchema)
@@ -425,6 +439,11 @@ async function pauseSubscription(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function resumeSubscription(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   logger.debug({ userId: user._id }, `resuming subscription`)
@@ -441,6 +460,11 @@ async function resumeSubscription(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function cancelSubscription(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   logger.debug({ userId: user._id }, 'canceling subscription')
@@ -456,6 +480,9 @@ async function cancelSubscription(req, res, next) {
 }
 
 /**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
  * @returns {Promise<void>}
  */
 async function canceledSubscription(req, res, next) {
@@ -467,20 +494,32 @@ async function canceledSubscription(req, res, next) {
   })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function cancelV1Subscription(req, res, next) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   logger.debug({ userId }, 'canceling v1 subscription')
-  V1SubscriptionManager.cancelV1Subscription(userId, function (err) {
-    if (err) {
-      OError.tag(err, 'something went wrong canceling v1 subscription', {
-        userId,
-      })
-      return next(err)
+  V1SubscriptionManager.cancelV1Subscription(
+    userId,
+    /** @param {any} err */ function (err) {
+      if (err) {
+        OError.tag(err, 'something went wrong canceling v1 subscription', {
+          userId,
+        })
+        return next(err)
+      }
+      res.redirect('/user/subscription')
     }
-    res.redirect('/user/subscription')
-  })
+  )
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function previewAddonPurchase(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   const userId = user._id
@@ -600,6 +639,11 @@ const purchaseAddonSchema = z.object({
   }),
 })
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function purchaseAddon(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const { params } = parseReq(req, purchaseAddonSchema)
@@ -642,22 +686,22 @@ async function purchaseAddon(req, res, next) {
       )
       return res.status(402).json({
         message: 'Payment action required',
-        clientSecret: err.info.clientSecret,
-        publicKey: err.info.publicKey,
+        clientSecret: /** @type {any} */ (err).info.clientSecret,
+        publicKey: /** @type {any} */ (err).info.publicKey,
       })
     } else if (err instanceof PaymentFailedError) {
       logger.debug(
         {
           userId: user._id,
-          reason: err.info.reason,
-          adviceCode: err.info.adviceCode,
+          reason: /** @type {any} */ (err).info.reason,
+          adviceCode: /** @type {any} */ (err).info.adviceCode,
         },
         'Payment failed for transaction'
       )
       return res.status(402).json({
         message: 'Payment failed',
-        reason: err.info.reason,
-        adviceCode: err.info.adviceCode,
+        reason: /** @type {any} */ (err).info.reason,
+        adviceCode: /** @type {any} */ (err).info.adviceCode,
       })
     } else if (err instanceof MultiplePendingChangesError) {
       logger.warn(
@@ -695,6 +739,11 @@ const removeAddonSchema = z.object({
   }),
 })
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function removeAddon(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const { params } = parseReq(req, removeAddonSchema)
@@ -749,6 +798,8 @@ const reactivateAddonSchema = z.object({
  * Reactivate an add-on pending cancellation
  *
  * This "cancels" the cancellation.
+ * @param {any} req
+ * @param {any} res
  */
 async function reactivateAddon(req, res) {
   const user = SessionManager.getSessionUser(req.session)
@@ -776,6 +827,11 @@ async function reactivateAddon(req, res) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function previewSubscription(req, res, next) {
   const planCode = req.query.planCode
   if (!planCode) {
@@ -822,24 +878,37 @@ async function previewSubscription(req, res, next) {
   })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function cancelPendingSubscriptionChange(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   logger.debug({ userId: user._id }, 'canceling pending subscription change')
-  SubscriptionHandler.cancelPendingSubscriptionChange(user, function (err) {
-    if (err) {
-      OError.tag(
-        err,
-        'something went wrong canceling pending subscription change',
-        {
-          user_id: user._id,
-        }
-      )
-      return next(err)
+  SubscriptionHandler.cancelPendingSubscriptionChange(
+    user,
+    /** @param {any} err */ function (err) {
+      if (err) {
+        OError.tag(
+          err,
+          'something went wrong canceling pending subscription change',
+          {
+            user_id: user._id,
+          }
+        )
+        return next(err)
+      }
+      res.redirect('/user/subscription')
     }
-    res.redirect('/user/subscription')
-  })
+  )
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function updateAccountEmailAddress(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   try {
@@ -854,6 +923,11 @@ async function updateAccountEmailAddress(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function reactivateSubscription(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   logger.debug({ userId: user._id }, 'reactivating subscription')
@@ -878,6 +952,11 @@ function reactivateSubscription(req, res, next) {
   })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function recurlyCallback(req, res, next) {
   logger.debug({ data: req.body }, 'received recurly callback')
   const event = Object.keys(req.body)[0]
@@ -926,6 +1005,10 @@ function recurlyCallback(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function extendTrial(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   const { subscription } =
@@ -951,20 +1034,36 @@ async function extendTrial(req, res) {
   res.sendStatus(200)
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function recurlyNotificationParser(req, res, next) {
   let xml = ''
-  req.on('data', chunk => (xml += chunk))
+  req.on('data', /** @param {any} chunk */ chunk => (xml += chunk))
   req.on('end', () =>
-    RecurlyWrapper._parseXml(xml, function (error, body) {
-      if (error) {
-        return next(error)
+    RecurlyWrapper._parseXml(
+      xml,
+      /**
+       * @param {any} error
+       * @param {any} body
+       */
+      function (error, body) {
+        if (error) {
+          return next(error)
+        }
+        req.body = body
+        next()
       }
-      req.body = body
-      next()
-    })
+    )
   )
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function refreshUserFeatures(req, res) {
   const { user_id: userId } = req.params
   await FeaturesUpdater.promises.refreshFeatures(userId, 'acceptance-test')
@@ -972,6 +1071,8 @@ async function refreshUserFeatures(req, res) {
 }
 
 /**
+ * @param {any} req
+ * @param {any} res
  * @returns {Promise<{currency: CurrencyCode, recommendedCurrency: CurrencyCode, countryCode: string|undefined}>}
  */
 async function getRecommendedCurrency(req, res) {
@@ -1002,6 +1103,10 @@ async function getRecommendedCurrency(req, res) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ */
 async function getLatamCountryBannerDetails(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   let ip = req.ip

+ 29 - 11
services/web/app/src/Features/Subscription/SubscriptionHandler.mjs

@@ -17,6 +17,9 @@ import { AI_ADD_ON_CODE } from './AiHelper.mjs'
  * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
  */
 
+/**
+ * @param {any} userId
+ */
 async function validateNoSubscriptionInRecurly(userId) {
   let subscriptions =
     await RecurlyWrapper.promises.listAccountActiveSubscriptions(userId)
@@ -37,6 +40,11 @@ async function validateNoSubscriptionInRecurly(userId) {
   return true
 }
 
+/**
+ * @param {any} user
+ * @param {any} subscriptionDetails
+ * @param {any} recurlyTokenIds
+ */
 async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
   const valid = await validateNoSubscriptionInRecurly(user._id)
 
@@ -82,7 +90,8 @@ async function previewSubscriptionChange(userId, planCode) {
 
 /**
  * @param user
- * @param planCode
+ * @param {any} user
+ * @param {any} planCode
  */
 async function updateSubscription(user, planCode) {
   let hasSubscription = false
@@ -115,7 +124,7 @@ async function updateSubscription(user, planCode) {
 }
 
 /**
- * @param user
+ * @param {any} user
  */
 async function cancelPendingSubscriptionChange(user) {
   const { hasSubscription, subscription } =
@@ -151,7 +160,7 @@ async function cancelPendingSubscriptionChange(user) {
 
 /**
  * Send cancellation email to user with split test for AI Assist addon
- * @param user
+ * @param {any} user
  */
 async function _sendCancellationEmail(user) {
   const emailOpts = {
@@ -174,7 +183,7 @@ async function _sendCancellationEmail(user) {
 }
 
 /**
- * @param user
+ * @param {any} user
  */
 async function cancelSubscription(user) {
   const { hasSubscription, subscription } =
@@ -187,7 +196,7 @@ async function cancelSubscription(user) {
 }
 
 /**
- * @param user
+ * @param {any} user
  */
 async function reactivateSubscription(user) {
   try {
@@ -220,8 +229,8 @@ async function reactivateSubscription(user) {
 }
 
 /**
- * @param recurlySubscription
- * @param requesterData
+ * @param {any} recurlySubscription
+ * @param {any} requesterData
  */
 async function syncSubscription(recurlySubscription, requesterData) {
   const storedSubscription = await RecurlyWrapper.promises.getSubscription(
@@ -250,7 +259,7 @@ async function syncSubscription(recurlySubscription, requesterData) {
  * This is used because Recurly doesn't always attempt collection of paast due
  * invoices after Paypal billing info were updated.
  *
- * @param recurlyAccountCode
+ * @param {any} recurlyAccountCode
  */
 async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
   const billingInfo =
@@ -274,6 +283,10 @@ async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
   )
 }
 
+/**
+ * @param {any} subscription
+ * @param {any} daysToExtend
+ */
 async function extendTrial(subscription, daysToExtend) {
   await Modules.promises.hooks.fire('extendTrial', subscription, daysToExtend)
 }
@@ -313,7 +326,7 @@ async function purchaseAddon(userId, addOnCode, quantity) {
 /**
  * Cancels an add-on for a user
  *
- * @param user
+ * @param {any} user
  * @param {string} addOnCode
  */
 async function removeAddon(user, addOnCode) {
@@ -334,6 +347,10 @@ async function reactivateAddon(userId, addOnCode) {
   await Modules.promises.hooks.fire('reactivateAddOn', userId, addOnCode)
 }
 
+/**
+ * @param {any} user
+ * @param {any} pauseCycles
+ */
 async function pauseSubscription(user, pauseCycles) {
   // only allow pausing on monthly plans not in a trial
   const { subscription } =
@@ -367,8 +384,9 @@ async function pauseSubscription(user, pauseCycles) {
     pauseCycles
   )
 }
-
-async function resumeSubscription(user) {
+/**
+ * @param {any} user
+ */ async function resumeSubscription(user) {
   const { subscription } =
     await LimitationsManager.promises.userHasSubscription(user)
   if (

+ 26 - 2
services/web/app/src/Features/Subscription/SubscriptionHelper.mjs

@@ -12,8 +12,8 @@ const MILLISECONDS = 1_000
  * This function checks if a subscription should transition between 'active' and 'paused'
  * states based on the current time and pause period metadata.
  *
- * @param {Object} subscription - The MongoDB subscription document
- * @returns {Promise<Object>} - The updated subscription document with recomputed state
+ * @param {any} subscription - The MongoDB subscription document
+ * @returns {Promise<any>} - The updated subscription document with recomputed state
  */
 async function recomputeSubscriptionState(subscription) {
   if (
@@ -65,6 +65,9 @@ async function recomputeSubscriptionState(subscription) {
 /**
  * If the user changes to a less expensive plan, we shouldn't apply the change immediately.
  * This is to avoid unintended/artifical credits on users Recurly accounts.
+ * @param {any} oldPlan
+ * @param {any} newPlan
+ * @param {any} isInTrial
  */
 function shouldPlanChangeAtTermEnd(oldPlan, newPlan, isInTrial) {
   if (isInTrial) {
@@ -138,6 +141,9 @@ function generateInitialLocalizedGroupPrice(recommendedCurrency, locale) {
   }
 }
 
+/**
+ * @param {any} subscription
+ */
 function isPaidSubscription(subscription) {
   const hasRecurlySubscription =
     subscription?.recurlySubscription_id &&
@@ -148,6 +154,9 @@ function isPaidSubscription(subscription) {
   return !!(subscription && (hasRecurlySubscription || hasStripeSubscription))
 }
 
+/**
+ * @param {any} subscription
+ */
 function isIndividualActivePaidSubscription(subscription) {
   return (
     isPaidSubscription(subscription) &&
@@ -157,6 +166,9 @@ function isIndividualActivePaidSubscription(subscription) {
   )
 }
 
+/**
+ * @param {any} subscription
+ */
 function getPaymentProviderSubscriptionId(subscription) {
   if (subscription?.recurlySubscription_id) {
     return subscription.recurlySubscription_id
@@ -167,6 +179,9 @@ function getPaymentProviderSubscriptionId(subscription) {
   return null
 }
 
+/**
+ * @param {any} subscription
+ */
 function getPaidSubscriptionState(subscription) {
   if (subscription?.recurlyStatus?.state) {
     return subscription.recurlyStatus.state
@@ -177,6 +192,9 @@ function getPaidSubscriptionState(subscription) {
   return null
 }
 
+/**
+ * @param {any} subscription
+ */
 function getSubscriptionTrialStartedAt(subscription) {
   if (subscription?.recurlyStatus?.trialStartedAt) {
     return subscription.recurlyStatus?.trialStartedAt
@@ -184,6 +202,9 @@ function getSubscriptionTrialStartedAt(subscription) {
   return subscription?.paymentProvider?.trialStartedAt
 }
 
+/**
+ * @param {any} subscription
+ */
 function getSubscriptionTrialEndsAt(subscription) {
   if (subscription?.recurlyStatus?.trialEndsAt) {
     return subscription.recurlyStatus?.trialEndsAt
@@ -191,6 +212,9 @@ function getSubscriptionTrialEndsAt(subscription) {
   return subscription?.paymentProvider?.trialEndsAt
 }
 
+/**
+ * @param {any} trialEndsAt
+ */
 function isInTrial(trialEndsAt) {
   if (!trialEndsAt) {
     return false

+ 34 - 0
services/web/app/src/Features/Subscription/TeamInvitesController.mjs

@@ -25,6 +25,11 @@ const rateLimiters = {
   }),
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function createInvite(req, res, next) {
   const teamManagerId = SessionManager.getLoggedInUserId(req.session)
   const subscription = req.entity
@@ -70,6 +75,11 @@ async function createInvite(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function viewInvite(req, res, next) {
   const { token } = req.params
   const sessionUser = SessionManager.getSessionUser(req.session)
@@ -186,6 +196,11 @@ async function viewInvite(req, res, next) {
   }
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function viewInvites(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const groupSubscriptions =
@@ -201,6 +216,11 @@ async function viewInvites(req, res, next) {
   })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function acceptInvite(req, res, next) {
   const { token } = req.params
   const userId = SessionManager.getLoggedInUserId(req.session)
@@ -232,6 +252,11 @@ async function acceptInvite(req, res, next) {
   res.json({ groupSSOActive })
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function revokeInvite(req, res, next) {
   const subscription = req.entity
   const email = EmailHelper.parseEmail(req.params.email)
@@ -244,6 +269,10 @@ function revokeInvite(req, res, next) {
     teamManagerId,
     subscription,
     email,
+    /**
+     * @param {any} err
+     * @param {any} results
+     */
     function (err, results) {
       if (err) {
         return next(err)
@@ -253,6 +282,11 @@ function revokeInvite(req, res, next) {
   )
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function resendInvite(req, res, next) {
   const { entity: subscription } = req
   const userEmail = EmailHelper.parseEmail(req.body.email)

+ 15 - 1
services/web/app/src/Features/Uploads/FileTypeManager.mjs

@@ -17,12 +17,17 @@ const FILE_IGNORE_MATCHER = new Minimatch(Settings.fileIgnorePattern, {
   dot: true,
 })
 
-const TEXT_EXTENSIONS = new Set(Settings.textExtensions.map(ext => `.${ext}`))
+const TEXT_EXTENSIONS = new Set(
+  Settings.textExtensions.map((/** @type {string} */ ext) => `.${ext}`)
+)
 const EDITABLE_FILENAMES = Settings.editableFilenames
 
 // allow 3 bytes for every character
 const MAX_TEXT_FILE_SIZE = 3 * Settings.max_doc_length
 
+/**
+ * @param {string} path
+ */
 async function isDirectory(path) {
   const stats = await fs.stat(path)
   return stats.isDirectory()
@@ -86,11 +91,17 @@ async function getType(name, fsPath, existingFileType) {
   }
 }
 
+/**
+ * @param {string} path
+ */
 function shouldIgnore(path) {
   // use minimatch file matching to check if the path should be ignored
   return FILE_IGNORE_MATCHER.match(path)
 }
 
+/**
+ * @param {string} filename
+ */
 function _isTextFilename(filename) {
   const basename = Path.basename(filename)
   const extension = Path.extname(filename).toLowerCase()
@@ -100,6 +111,9 @@ function _isTextFilename(filename) {
   )
 }
 
+/**
+ * @param {Buffer<ArrayBufferLike>} bytes
+ */
 function _detectEncoding(bytes) {
   if (isUtf8(bytes)) {
     return 'utf-8'

+ 33 - 14
services/web/app/src/Features/Uploads/ProjectUploadController.mjs

@@ -28,6 +28,11 @@ const upload = multer(
   )
 )
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function uploadProject(req, res, next) {
   const timer = new metrics.Timer('project-upload')
   const userId = SessionManager.getLoggedInUserId(req.session)
@@ -63,6 +68,11 @@ function uploadProject(req, res, next) {
   )
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 async function uploadFile(req, res, next) {
   const timer = new metrics.Timer('file-upload')
   const name = req.body.name
@@ -156,27 +166,36 @@ async function uploadFile(req, res, next) {
   )
 }
 
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function multerMiddleware(req, res, next) {
   if (upload == null) {
     return res
       .status(500)
       .json({ success: false, error: req.i18n.translate('upload_failed') })
   }
-  return upload.single('qqfile')(req, res, function (err) {
-    if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
-      return res
-        .status(422)
-        .json({ success: false, error: req.i18n.translate('file_too_large') })
-    }
-    if (err) return next(err)
-    if (!req.file?.path) {
-      logger.info({ req }, 'missing req.file.path on upload')
-      return res
-        .status(400)
-        .json({ success: false, error: 'invalid_upload_request' })
+  return upload.single('qqfile')(
+    req,
+    res,
+    /** @param {any} err */ function (err) {
+      if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
+        return res
+          .status(422)
+          .json({ success: false, error: req.i18n.translate('file_too_large') })
+      }
+      if (err) return next(err)
+      if (!req.file?.path) {
+        logger.info({ req }, 'missing req.file.path on upload')
+        return res
+          .status(400)
+          .json({ success: false, error: 'invalid_upload_request' })
+      }
+      next()
     }
-    next()
-  })
+  )
 }
 
 export default {

+ 18 - 18
services/web/app/src/Features/User/UserGetter.mjs

@@ -274,6 +274,24 @@ const UserGetter = {
   // check for duplicate email address. This is also enforced at the DB level
   ensureUniqueEmailAddress: callbackify(ensureUniqueEmailAddress),
   getWritefullData: callbackify(getWritefullData),
+
+  promises: {
+    getSsoUsersAtInstitution,
+    getUser,
+    getUserFeatures,
+    getUserEmail,
+    getUserFullEmails,
+    getUserConfirmedEmails,
+    getUserByMainEmail,
+    getUserByAnyEmail,
+    getUsersByAnyConfirmedEmail,
+    getUsersByV1Ids,
+    getUsersByHostname,
+    getInstitutionUsersByHostname,
+    getUsers,
+    ensureUniqueEmailAddress,
+    getWritefullData,
+  },
 }
 
 const decorateFullEmails = (
@@ -352,22 +370,4 @@ const decorateFullEmails = (
   return emailsData
 }
 
-UserGetter.promises = {
-  getSsoUsersAtInstitution,
-  getUser,
-  getUserFeatures,
-  getUserEmail,
-  getUserFullEmails,
-  getUserConfirmedEmails,
-  getUserByMainEmail,
-  getUserByAnyEmail,
-  getUsersByAnyConfirmedEmail,
-  getUsersByV1Ids,
-  getUsersByHostname,
-  getInstitutionUsersByHostname,
-  getUsers,
-  ensureUniqueEmailAddress,
-  getWritefullData,
-}
-
 export default UserGetter

+ 94 - 35
services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs

@@ -36,7 +36,12 @@ const UserMembershipMiddleware = {
     requireEntity(),
   ],
 
-  requireEntityAccess: ({ entityName, adminCapability }) => [
+  requireEntityAccess: (
+    /** @type {{ entityName: any; adminCapability?: any }} */ {
+      entityName,
+      adminCapability,
+    }
+  ) => [
     AuthenticationController.requireLogin(),
     fetchEntityConfig(entityName),
     fetchEntity(),
@@ -50,7 +55,7 @@ const UserMembershipMiddleware = {
     ),
   ],
 
-  requireEntityAccessOrAdminAccess: entityName => [
+  requireEntityAccessOrAdminAccess: (/** @type {any} */ entityName) => [
     AuthenticationController.requireLogin(),
     fetchEntityConfig(entityName),
     fetchEntity(),
@@ -61,7 +66,7 @@ const UserMembershipMiddleware = {
     ]),
   ],
 
-  requireGroupMemberManagement: entityName => [
+  requireGroupMemberManagement: (/** @type {any} */ entityName) => [
     AuthenticationController.requireLogin(),
     fetchEntityConfig(entityName),
     fetchEntity(),
@@ -223,10 +228,20 @@ const UserMembershipMiddleware = {
 
 export default UserMembershipMiddleware
 
-// fetch entity config and set it in the request
+/**
+ * fetch entity config and set it in the request
+ *
+ * @param {any} entityName
+ */
 function fetchEntityConfig(entityName) {
-  return (req, res, next) => {
-    const entityConfig = EntityConfigs[entityName]
+  return (
+    /** @type {any} */ req,
+    /** @type {any} */ res,
+    /** @type {any} */ next
+  ) => {
+    const entityConfig = /** @type {Record<string, any>} */ (EntityConfigs)[
+      entityName
+    ]
     req.entityName = entityName
     req.entityConfig = entityConfig
     next()
@@ -271,6 +286,11 @@ const fetchEntitySchema = z.discriminatedUnion('entityName', [
 // `req.params.id`
 // - the entity name is in `req.query.resource_type` and is used to find the
 // require middleware depending on the entity name
+/**
+ * @param {any} req
+ * @param {any} res
+ * @param {any} next
+ */
 function requireGraphAccess(req, res, next) {
   const entityName = req.query.resource_type
   if (!entityName) {
@@ -279,8 +299,9 @@ function requireGraphAccess(req, res, next) {
   const middleWareName =
     entityName.charAt(0).toUpperCase() + entityName.slice(1)
 
-  const middlewares =
-    UserMembershipMiddleware[`require${middleWareName}MetricsAccess`]
+  const middlewares = /** @type {Record<string, any>} */ (
+    UserMembershipMiddleware
+  )[`require${middleWareName}MetricsAccess`]
   if (!middlewares) {
     return HttpErrorHandler.notFound(
       req,
@@ -305,19 +326,29 @@ function requireGraphAccess(req, res, next) {
 
 // fetch the entity with id and config, and set it in the request
 function fetchEntity() {
-  return expressify(async (req, res, next) => {
-    const { params } = parseReq(req, fetchEntitySchema)
-    req.entity =
-      await UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
-        params.id,
-        req.entityConfig
-      )
-    next()
-  })
+  return expressify(
+    async (
+      /** @type {any} */ req,
+      /** @type {any} */ res,
+      /** @type {any} */ next
+    ) => {
+      const { params } = parseReq(req, fetchEntitySchema)
+      req.entity =
+        await UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
+          params.id,
+          req.entityConfig
+        )
+      next()
+    }
+  )
 }
 
 function fetchPublisherFromTemplate() {
-  return (req, res, next) => {
+  return (
+    /** @type {any} */ req,
+    /** @type {any} */ res,
+    /** @type {any} */ next
+  ) => {
     if (req.template.brand.slug) {
       // set the id as the publisher's id as it's the entity used for access
       // control
@@ -331,7 +362,11 @@ function fetchPublisherFromTemplate() {
 
 // ensure an entity was found, or fail with 404
 function requireEntity() {
-  return (req, res, next) => {
+  return (
+    /** @type {any} */ req,
+    /** @type {any} */ res,
+    /** @type {any} */ next
+  ) => {
     if (req.entity) {
       return next()
     }
@@ -342,10 +377,16 @@ function requireEntity() {
   }
 }
 
-// ensure an entity was found or redirect to entity creation page if the user
-// has permissions to create the entity, or fail with 404
+/**
+ * ensure an entity was found or redirect to entity creation page if the user
+ * has permissions to create the entity, or fail with 404
+ */
 function requireEntityOrCreate() {
-  return (req, res, next) => {
+  return (
+    /** @type {any} */ req,
+    /** @type {any} */ res,
+    /** @type {any} */ next
+  ) => {
     if (req.entity) {
       return next()
     }
@@ -363,21 +404,31 @@ function requireEntityOrCreate() {
 
 // fetch the template from v1, and set it in the request
 function fetchV1Template() {
-  return expressify(async (req, res, next) => {
-    const templateId = req.params.id
-    const body = await TemplatesManager.promises.fetchFromV1(templateId)
-    req.template = {
-      id: body.id,
-      title: body.title,
-      brand: body.brand,
+  return expressify(
+    async (
+      /** @type {any} */ req,
+      /** @type {any} */ res,
+      /** @type {any} */ next
+    ) => {
+      const templateId = req.params.id
+      const body = await TemplatesManager.promises.fetchFromV1(templateId)
+      req.template = {
+        id: body.id,
+        title: body.title,
+        brand: body.brand,
+      }
+      next()
     }
-    next()
-  })
+  )
 }
 
 // ensure a template was found, or fail with 404
 function requireV1Template() {
-  return (req, res, next) => {
+  return (
+    /** @type {any} */ req,
+    /** @type {any} */ res,
+    /** @type {any} */ next
+  ) => {
     if (req.template.id) {
       return next()
     }
@@ -386,10 +437,18 @@ function requireV1Template() {
   }
 }
 
-// run a serie of synchronous access functions and call `next` if any of the
-// retur values is truly. Redirect to restricted otherwise
+/**
+ * run a series of synchronous access functions and call `next` if any of the
+ * return values is truly. Redirect to restricted otherwise
+ *
+ * @param {any} accessFunctions
+ */
 function allowAccessIfAny(accessFunctions) {
-  return (req, res, next) => {
+  return (
+    /** @type {any} */ req,
+    /** @type {any} */ res,
+    /** @type {any} */ next
+  ) => {
     for (const accessFunction of accessFunctions) {
       if (accessFunction(req)) {
         return next()

+ 2 - 2
services/web/app/src/infrastructure/AsyncLocalStorage.mjs

@@ -2,8 +2,8 @@
 import { AsyncLocalStorage } from 'node:async_hooks'
 
 /**
- * @typedef {Object} RequestContext
- * @property {Object.<string, array>} [userFullEmails] - Dictionary mapping userId to an array of full emails
+ * @typedef {Record<string, any>} RequestContext
+ * @property {Record<string, any[]>} [userFullEmails] - Dictionary mapping userId to an array of full emails
  */
 
 /** @type {AsyncLocalStorage<RequestContext>} */

+ 8 - 0
services/web/app/src/infrastructure/GeoIpLookup.mjs

@@ -73,6 +73,9 @@ for (const country of EuroCountries) {
   currencyMappings[country] = 'EUR'
 }
 
+/**
+ * @param {any} currency
+ */
 function isValidCurrencyParam(currency) {
   if (!currency) {
     return false
@@ -80,6 +83,10 @@ function isValidCurrencyParam(currency) {
   return validCurrencyParams.includes(currency)
 }
 
+/**
+ * @param {any} ip
+ * @param {any} [callback]
+ */
 async function getDetails(ip, callback) {
   if (!ip) {
     return callback(new Error('no ip passed'))
@@ -92,6 +99,7 @@ async function getDetails(ip, callback) {
 }
 
 /**
+ * @param {any} ip
  * @returns {Promise<{currencyCode: CurrencyCode, countryCode: string|undefined}>}
  */
 async function getCurrencyCode(ip) {

+ 6 - 1
services/web/app/src/infrastructure/HttpPermissionsPolicy.mjs

@@ -72,11 +72,16 @@ class HttpPermissionsPolicyMiddleware {
     return policyElements.join(', ')
   }
 
+  /**
+   * @param {any} req
+   * @param {any} res
+   * @param {any} next
+   */
   middleware(req, res, next) {
     if (this.policy && Settings.useHttpPermissionsPolicy) {
       const originalRender = res.render
 
-      res.render = (...args) => {
+      res.render = (/** @type {any} */ ...args) => {
         res.setHeader('Permissions-Policy', this.policy)
         originalRender.apply(res, args)
       }

+ 48 - 6
services/web/app/src/infrastructure/Modules.mjs

@@ -17,10 +17,12 @@ const MODULE_BASE_PATH = Path.join(import.meta.dirname, '/../../../modules')
 /** @type {WebModule[]} */
 const _modules = []
 let _modulesLoaded = false
+/** @type {Record<string, any>} */
 const _hooks = {}
 
 /** @type {Record<string, RequestHandler[]>} */
 const _middleware = {}
+/** @type {Record<string, any>} */
 let _viewIncludes = {}
 
 async function modules() {
@@ -74,6 +76,11 @@ async function loadModulesImpl() {
 
 const loadModules = _.memoize(loadModulesImpl)
 
+/**
+ * @param {any} webRouter
+ * @param {any} privateApiRouter
+ * @param {any} publicApiRouter
+ */
 async function applyRouter(webRouter, privateApiRouter, publicApiRouter) {
   for (const module of await modules()) {
     if (module.router && module.router.apply) {
@@ -82,6 +89,11 @@ async function applyRouter(webRouter, privateApiRouter, publicApiRouter) {
   }
 }
 
+/**
+ * @param {any} webRouter
+ * @param {any} privateApiRouter
+ * @param {any} publicApiRouter
+ */
 async function applyNonCsrfRouter(
   webRouter,
   privateApiRouter,
@@ -107,10 +119,18 @@ async function start() {
   }
 }
 
+/**
+ * @param {any} app
+ */
 function loadViewIncludes(app) {
   _viewIncludes = Views.compileViewIncludes(app)
 }
 
+/**
+ * @param {any} appOrRouter
+ * @param {any} middlewareName
+ * @param {any} [options]
+ */
 async function applyMiddleware(appOrRouter, middlewareName, options) {
   if (!middlewareName) {
     throw new Error(
@@ -118,30 +138,42 @@ async function applyMiddleware(appOrRouter, middlewareName, options) {
     )
   }
   for (const module of await modules()) {
-    if (module[middlewareName]) {
-      module[middlewareName](appOrRouter, options)
+    /** @type {Record<string, any>} */
+    const typedModule = module
+    if (typedModule[middlewareName]) {
+      typedModule[middlewareName](appOrRouter, options)
     }
   }
 }
 
+/**
+ * @param {any} view
+ * @param {any} locals
+ */
 function moduleIncludes(view, locals) {
   const compiledPartials = _viewIncludes[view] || []
   let html = ''
-  for (const compiledPartial of compiledPartials) {
+  for (const /** @type {any} */ compiledPartial of compiledPartials) {
     html += compiledPartial(locals)
   }
   return html
 }
 
+/**
+ * @param {any} view
+ */
 function moduleIncludesAvailable(view) {
   return (_viewIncludes[view] || []).length > 0
 }
 
 async function linkedFileAgentsIncludes() {
+  /** @type {Record<string, any>} */
   const agents = {}
   for (const module of await modules()) {
     for (const name in module.linkedFileAgents) {
-      const agentFunction = module.linkedFileAgents[name]
+      const agentFunction = /** @type {Record<string, any>} */ (
+        module.linkedFileAgents
+      )[name]
       agents[name] = agentFunction()
     }
   }
@@ -155,12 +187,16 @@ async function attachHooks() {
       attachHook(hook, method)
     }
     for (const hook in hooks || {}) {
-      const method = hooks[hook]
+      const method = /** @type {Record<string, any>} */ (hooks)[hook]
       attachHook(hook, promisify(method))
     }
   }
 }
 
+/**
+ * @param {any} name
+ * @param {any} method
+ */
 function attachHook(name, method) {
   if (_hooks[name] == null) {
     _hooks[name] = []
@@ -172,7 +208,9 @@ async function attachMiddleware() {
   for (const module of await modules()) {
     if (module.middleware) {
       for (const middleware in module.middleware) {
-        const method = module.middleware[middleware]
+        const method = /** @type {Record<string, any>} */ (module.middleware)[
+          middleware
+        ]
         if (_middleware[middleware] == null) {
           _middleware[middleware] = []
         }
@@ -182,6 +220,10 @@ async function attachMiddleware() {
   }
 }
 
+/**
+ * @param {any} name
+ * @param {...any} args
+ */
 async function fireHook(name, ...args) {
   // ensure that modules are loaded if we need to fire a hook
   // this can happen if a script calls a method that fires a hook

+ 5 - 1
services/web/app/src/infrastructure/Validation.mjs

@@ -11,10 +11,14 @@ import {
 
 export { z, zz } from '@overleaf/validation-tools'
 
+/**
+ * @param {any} req
+ * @param {any} schema
+ */
 export const parseReq = (req, schema) => {
   try {
     return parseReqBase(req, schema)
-  } catch (err) {
+  } catch (/** @type {any} */ err) {
     if (err instanceof ParamsError) {
       // convert into a NotFoundError that web understands
       throw new NotFoundError('Not found').withCause(err)

+ 14 - 3
services/web/app/src/infrastructure/rate-limiters/FeatureUsageRateLimiter.mjs

@@ -88,7 +88,12 @@ export default class FeatureUsageRateLimiter {
         upsert: true,
       }
     ).exec()
-    const featureUsage = featureUsages.features?.[this.featureName] ?? {}
+
+    const featureUsage =
+      /** @type {Record<string, any>} */ (featureUsages.features ?? {})[
+        this.featureName
+      ] ?? {}
+
     setRateLimitHeaders(res, featureUsage, allowance)
     this._checkRateLimit(featureUsage, allowance)
   }
@@ -118,7 +123,10 @@ export default class FeatureUsageRateLimiter {
       }
     ).exec()
 
-    const featureUsage = featureUsages.features?.[this.featureName] ?? {}
+    const featureUsage =
+      /** @type {Record<string, any>} */ (featureUsages.features ?? {})[
+        this.featureName
+      ] ?? {}
     setRateLimitHeaders(res, featureUsage, allowance)
   }
 
@@ -129,7 +137,10 @@ export default class FeatureUsageRateLimiter {
   async getRemainingFeatureUses(userId) {
     const allowance = await this._getAllowance(userId)
     const reportedUsage = await UserFeatureUsage.findOne({ _id: userId }).exec()
-    const featureUsage = reportedUsage?.features?.[this.featureName] ?? {}
+    const featureUsage =
+      /** @type {Record<string, any>} */ (reportedUsage?.features ?? {})[
+        this.featureName
+      ] ?? {}
     const periodStart = featureUsage.periodStart ?? new Date()
     const usage = featureUsage.usage ?? 0
     const usesLeft = allowance - usage

+ 10 - 2
services/web/app/src/infrastructure/rate-limiters/TokenUsageRateLimiter.mjs

@@ -57,9 +57,15 @@ export default class TokenUsageRateLimiter {
     throw new Error('_getAllowance must be implemented by subclasses')
   }
 
+  /**
+   * @param {any} userId
+   * @param {any} res
+   * @param {any} amount
+   */
   async recordUsage(userId, res, amount) {
     const allowance = await this._getAllowance(userId)
 
+    /** @type {any} */
     const featureUsages = await UserFeatureUsage.findOneAndUpdate(
       { _id: userId },
       [
@@ -93,7 +99,8 @@ export default class TokenUsageRateLimiter {
    */
   async getCurrentUsage(userId) {
     const reportedUsage = await UserFeatureUsage.findOne({ _id: userId }).exec()
-    const featureUsage = reportedUsage?.features?.[this.featureName] ?? {}
+    const featureUsage =
+      /** @type {any} */ (reportedUsage)?.features?.[this.featureName] ?? {}
     return {
       usage: featureUsage.usage ?? 0,
       periodStart: featureUsage.periodStart ?? new Date(),
@@ -112,7 +119,8 @@ export default class TokenUsageRateLimiter {
   async getRemainingTokens(userId) {
     const allowance = await this._getAllowance(userId)
     const reportedUsage = await UserFeatureUsage.findOne({ _id: userId }).exec()
-    const featureUsage = reportedUsage?.features?.[this.featureName] ?? {}
+    const featureUsage =
+      /** @type {any} */ (reportedUsage)?.features?.[this.featureName] ?? {}
     const periodStart = featureUsage.periodStart ?? new Date()
     const usage = featureUsage.usage ?? 0
     const usesLeft = allowance - usage

+ 3 - 1
services/web/frontend/stylesheets/abstracts/mixins.scss

@@ -1,3 +1,5 @@
+@use 'sass:list';
+
 // Button
 
 @mixin ol-button-size(
@@ -105,7 +107,7 @@
 }
 
 @mixin theme($name) {
-  @if index($themes, $name) {
+  @if list.index($themes, $name) {
     [data-theme='#{$name}'] {
       @content;
     }

+ 4 - 0
services/web/package.json

@@ -253,6 +253,7 @@
     "@testing-library/react": "^16.3.0",
     "@testing-library/user-event": "^14.4.3",
     "@types/algoliasearch": "^3.34.11",
+    "@types/async": "^3.2.25",
     "@types/bootstrap": "^5.2.10",
     "@types/chai": "^4.3.0",
     "@types/dateformat": "^5.0.3",
@@ -260,6 +261,7 @@
     "@types/dom-speech-recognition": "^0.0.7",
     "@types/events": "^3.0.3",
     "@types/express": "^4.17.23",
+    "@types/minimist": "^1.2.5",
     "@types/mocha": "^9.1.0",
     "@types/mocha-each": "^2.0.4",
     "@types/node": "^24.5.2",
@@ -270,7 +272,9 @@
     "@types/react-linkify": "^1.0.4",
     "@types/react-syntax-highlighter": "^15.5.11",
     "@types/recurly__recurly-js": "^4.38.0",
+    "@types/sanitize-html": "^2.14.0",
     "@types/sinon-chai": "^3.2.12",
+    "@types/utf-8-validate": "^5.0.2",
     "@types/uuid": "^9.0.8",
     "@uppy/core": "^3.8.0",
     "@uppy/dashboard": "^3.7.1",

+ 4 - 0
services/web/scripts/attach_dangling_comments_to_doc.mjs

@@ -65,6 +65,10 @@ async function getDanglingThreads(projectId) {
   return danglingThreads
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} docId
+ */
 const ensureDocExists = async (projectId, docId) => {
   const doc = await DocstoreManager.promises.getDoc(projectId, docId)
   if (!doc) {

+ 16 - 0
services/web/scripts/backfill_mixpanel_user_properties.mjs

@@ -13,6 +13,9 @@ const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY || '10', 10)
 
 const mixpanelSinkQueue = getQueue('analytics-mixpanel-sink')
 
+/**
+ * @param {any} user
+ */
 async function processUser(user) {
   const analyticsId = user.analyticsId || user._id
 
@@ -58,6 +61,9 @@ async function processUser(user) {
   }
 }
 
+/**
+ * @param {any} userId
+ */
 async function _getGroupSubscriptionPlanCode(userId) {
   const subscriptions =
     await SubscriptionLocator.promises.getMemberSubscriptions(userId)
@@ -77,6 +83,12 @@ async function _getGroupSubscriptionPlanCode(userId) {
   return bestPlanCode
 }
 
+/**
+ * @param {any} analyticsId
+ * @param {any} propertyName
+ * @param {any} propertyValue
+ * @param {any} [createdAt]
+ */
 async function _sendPropertyToQueue(
   analyticsId,
   propertyName,
@@ -94,6 +106,10 @@ async function _sendPropertyToQueue(
   })
 }
 
+/**
+ * @param {any} _
+ * @param {any} users
+ */
 async function processBatch(_, users) {
   await promiseMapWithLimit(WRITE_CONCURRENCY, users, async user => {
     await processUser(user)

+ 13 - 0
services/web/scripts/check_docs.mjs

@@ -149,6 +149,9 @@ function getProjectIds() {
     .map(x => x._id.toString())
 }
 
+/**
+ * @param {any} projectId
+ */
 async function getDocs(projectId) {
   const mongoDocs = db.docs.find(
     {
@@ -192,6 +195,10 @@ async function getDocs(projectId) {
   return docs
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} docs
+ */
 async function findDanglingThreadIds(projectId, docs) {
   const threadIds = new Set()
   for (const doc of docs) {
@@ -219,6 +226,9 @@ async function findDanglingThreadIds(projectId, docs) {
   return Array.from(threadIds)
 }
 
+/**
+ * @param {any} docs
+ */
 function docsHaveTrackedChanges(docs) {
   for (const doc of docs) {
     const changes = doc.ranges?.changes ?? []
@@ -229,6 +239,9 @@ function docsHaveTrackedChanges(docs) {
   return false
 }
 
+/**
+ * @param {any} docs
+ */
 function docsHaveAnyComments(docs) {
   for (const doc of docs) {
     const comments = doc.ranges?.comments ?? []

+ 3 - 0
services/web/scripts/convert_track_changes_to_explicit_format.mjs

@@ -48,6 +48,9 @@ async function main(trackProgress) {
   )
 }
 
+/**
+ * @param {any} project
+ */
 async function processProject(project) {
   if (DEBUG) {
     console.log(

+ 12 - 0
services/web/scripts/delete_dangling_comments.mjs

@@ -29,6 +29,9 @@ function parseArgs() {
   }
 }
 
+/**
+ * @param {any} projectId
+ */
 async function processProject(projectId) {
   console.log(`Processing project ${projectId}...`)
   await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId)
@@ -49,6 +52,11 @@ async function processProject(projectId) {
   }
 }
 
+/**
+ * @param {any} projectId
+ * @param {any} doc
+ * @param {any} threadIds
+ */
 async function processDoc(projectId, doc, threadIds) {
   let commentsDeleted = 0
   for (const comment of doc.ranges?.comments ?? []) {
@@ -66,6 +74,10 @@ async function processDoc(projectId, doc, threadIds) {
   return commentsDeleted
 }
 
+/**
+ * @param {any} docId
+ * @param {any} threadId
+ */
 async function deleteComment(docId, threadId) {
   await db.docs.updateOne(
     { _id: new ObjectId(docId) },

+ 6 - 0
services/web/scripts/fix_comment_id.mjs

@@ -24,6 +24,9 @@ function parseArgs() {
   }
 }
 
+/**
+ * @param {any} projectId
+ */
 async function processProject(projectId) {
   console.log(`Processing project ${projectId}...`)
   const docRanges = await DocstoreManager.promises.getAllRanges(projectId)
@@ -38,6 +41,9 @@ async function processProject(projectId) {
   }
 }
 
+/**
+ * @param {any} doc
+ */
 async function processDoc(doc) {
   let commentsUpdated = 0
   for (const comment of doc.ranges.comments ?? []) {

+ 20 - 7
services/web/scripts/fix_unconfirmed_secondaries_not_removed_v1.mjs

@@ -42,6 +42,9 @@ if (!doNotListUsers) {
  */
 const parseAsync = promisify(csv.parse)
 
+/**
+ * @param {any} userId
+ */
 async function getV1Affiliations(userId) {
   const url = `${Settings.apis.v1.url}/api/v2/users/${userId}/affiliations`
 
@@ -56,6 +59,10 @@ async function getV1Affiliations(userId) {
   return affiliations
 }
 
+/**
+ * @param {any} userId
+ * @param {any} email
+ */
 async function removeAffiliationV1(userId, email) {
   const url = `${Settings.apis.v1.url}/api/v2/users/${userId}/affiliations/remove`
 
@@ -92,6 +99,9 @@ const results = {
   errorRemovingAffiliationInV1: [],
 }
 
+/**
+ * @param {any} trackProgress
+ */
 async function main(trackProgress) {
   console.time('check_removed_emails')
 
@@ -99,6 +109,7 @@ async function main(trackProgress) {
   const csvContent = await fs.readFile(filePath, 'utf8')
   const rows = await parseAsync(csvContent)
   rows.shift() // Remove header row
+  /** @type {Record<string, string[]>} */
   const emailsByUserId = {}
 
   for (const [userId, email] of rows) {
@@ -124,12 +135,12 @@ async function main(trackProgress) {
         // nothing to cleanup in v1 if no affiliations for the user
         continue
       }
-    } catch (e) {
+    } catch (/** @type {any} */ e) {
       results.errorCheckingAffiliations.push(userId)
     }
 
     const affiliationsEmailsInV1 = affiliations.map(
-      affiliation => affiliation.email
+      (/** @type {any} */ affiliation) => affiliation.email
     )
 
     const user = await db.users.findOne(
@@ -151,7 +162,9 @@ async function main(trackProgress) {
         continue
       }
 
-      const emailOnAccount = user?.emails?.find(e => e.email === email)
+      const emailOnAccount = user?.emails?.find(
+        (/** @type {any} */ e) => e.email === email
+      )
 
       if (emailOnAccount) {
         // the email is still on the user account, we should not remove the affiliation in v1
@@ -181,7 +194,7 @@ async function main(trackProgress) {
           // remove the affiliation in v1
           await removeAffiliationV1(userId, email)
           results.successfullyRemovedEmailInV1ForUser.push({ userId, email })
-        } catch (e) {
+        } catch (/** @type {any} */ e) {
           results.errorRemovingAffiliationInV1.push({
             userId,
             email,
@@ -200,18 +213,18 @@ async function main(trackProgress) {
 
   console.log('Results:')
   for (const key in results) {
-    console.log(`   ${key}:`, results[key].length)
+    console.log(`   ${key}:`, /** @type {any} */ (results)[key].length)
   }
   for (const key in results) {
     if (
       !doNotListUsers &&
-      results[key].length > 0 &&
+      /** @type {any} */ (results)[key].length > 0 &&
       key !== 'needToRemoveEmailInV1'
     ) {
       // skip needToRemoveEmailInV1 since we'll only output that if this list length does not match success list length
       console.log('----------------------------')
       console.log(`${key}:`)
-      console.log(results[key])
+      console.log(/** @type {any} */ (results)[key])
     }
   }
 

+ 12 - 7
services/web/scripts/recurly/generate_addon_prices.mjs

@@ -14,6 +14,7 @@ async function main() {
     process.exit(1)
   }
 
+  /** @type {Record<string, any>} */
   const localizedAddOnsPricing = {}
 
   const monthlyPlan = await getPlan(ADD_ON_CODE)
@@ -22,10 +23,12 @@ async function main() {
     process.exit(1)
   }
   for (const { currency, unitAmount } of monthlyPlan.currencies ?? []) {
-    if (!localizedAddOnsPricing[currency]) {
-      localizedAddOnsPricing[currency] = { [ADD_ON_CODE]: {} }
+    /** @type {any} */
+    const curr = currency
+    if (!localizedAddOnsPricing[curr]) {
+      localizedAddOnsPricing[curr] = { [ADD_ON_CODE]: {} }
     }
-    localizedAddOnsPricing[currency][ADD_ON_CODE].monthly = unitAmount
+    localizedAddOnsPricing[curr][ADD_ON_CODE].monthly = unitAmount
   }
 
   const annualPlan = await getPlan(`${ADD_ON_CODE}-annual`)
@@ -34,11 +37,13 @@ async function main() {
     process.exit(1)
   }
   for (const { currency, unitAmount } of annualPlan.currencies ?? []) {
-    if (!localizedAddOnsPricing[currency]) {
-      localizedAddOnsPricing[currency] = { [ADD_ON_CODE]: {} }
+    /** @type {any} */
+    const curr = currency
+    if (!localizedAddOnsPricing[curr]) {
+      localizedAddOnsPricing[curr] = { [ADD_ON_CODE]: {} }
     }
-    localizedAddOnsPricing[currency][ADD_ON_CODE].annual = unitAmount
-    localizedAddOnsPricing[currency][ADD_ON_CODE].annualDividedByTwelve =
+    localizedAddOnsPricing[curr][ADD_ON_CODE].annual = unitAmount
+    localizedAddOnsPricing[curr][ADD_ON_CODE].annualDividedByTwelve =
       (unitAmount || 0) / 12
   }
 

+ 3 - 0
services/web/scripts/recurly/set_manually_collected_subscriptions.mjs

@@ -73,6 +73,9 @@ function usage() {
     where FILE contains the list of subscription ids that are manually collected`)
 }
 
+/**
+ * @param {any} filename
+ */
 function readFile(filename) {
   const contents = fs.readFileSync(filename, { encoding: 'utf-8' })
   const subscriptionIds = contents.split('\n').filter(id => id.length > 0)

+ 10 - 2
services/web/scripts/stripe/create_prices_from_csv.mjs

@@ -62,6 +62,7 @@ async function getExistingPrices(stripe) {
   let startingAfter
 
   do {
+    /** @type {any} */
     const response = await stripe.prices.list({
       limit: 100,
       starting_after: startingAfter,
@@ -89,6 +90,7 @@ async function getExistingProducts(stripe) {
   let startingAfter
 
   do {
+    /** @type {any} */
     const response = await stripe.products.list({
       limit: 100,
       starting_after: startingAfter,
@@ -104,6 +106,9 @@ async function getExistingProducts(stripe) {
   return productsById
 }
 
+/**
+ * @param {any} trackProgress
+ */
 export async function main(trackProgress) {
   const args = minimist(process.argv.slice(2), {
     boolean: ['commit'],
@@ -175,7 +180,10 @@ export async function main(trackProgress) {
         record.productName ||
         planCode
           .split(/[_-]/) // Handle underscores or hyphens
-          .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+          .map(
+            /** @param {any} word */
+            word => word.charAt(0).toUpperCase() + word.slice(1)
+          )
           .join(' ')
 
       if (commit) {
@@ -203,7 +211,7 @@ export async function main(trackProgress) {
 
     // 2. Handle Prices for each currency column
     for (const currency of currencyKeys) {
-      const amountValue = parseFloat(record[currency])
+      const amountValue = parseFloat(/** @type {any} */ (record)[currency])
       if (isNaN(amountValue) || amountValue <= 0) continue
 
       const currencyLower = currency.toLowerCase()

+ 3 - 0
services/web/tsconfig.backend.json

@@ -1,5 +1,8 @@
 {
   "extends": "../../tsconfig.backend.json",
+  "compilerOptions": {
+    "noImplicitAny": true
+  },
   "include": [
     "app/src/**/*",
     "modules/*/app/src/**/*",