Repository: firebase/firebase-tools (ts)

Issue:
Document complete schema for `firebase.json`
<!-- DO NOT DELETE 
validate_template=false
template_path=.github/ISSUE_TEMPLATE/feature_request.md
-->

<!--
Thank you for contributing to the Firebase community!

Have a feature request?
========================
Great, we love hearing how we can improve our products! However, GitHub is not the place to submit them. Please submit your feature requests to:
https://firebase.google.com/support/contact/bugs-features/

**Think you found a bug?** Please do not use this template - use the `🐛 Bug report` template.

Have a usage question?
=======================
We get lots of those and we love helping you, but GitHub is not the best place for them and they
will be closed. Here are some resources to get help:

- Start with the quickstart: https://firebase.google.com/docs/functions/write-firebase-functions
- Go through the guide: https://firebase.google.com/docs/functions/
- Read the full API reference: https://firebase.google.com/docs/reference/functions/
- Browse some examples: https://github.com/firebase/functions-samples

If the official documentation doesn't help, try asking through our official support channels:

https://firebase.google.com/support/

*Please avoid double posting across multiple channels!*
-->

Originally suggested by @matheo here:
https://github.com/firebase/firebase-tools/issues/1303#issuecomment-502407506

We should document the complete schema of `firebase.json` in both human-readable and machine-readable formats.  This file has expanded a lot and it's not always clear what's there and what the defaults are.


Pull request description:
Generate JSON Schema for firebase.json
<!--

Thank you for contributing to the Firebase community! Please fill out the form below.

Run the linter and test suite
==============================
Run `npm test` to make sure your changes compile properly and the tests all pass on your local machine. We've hooked up this repo with continuous integration to double check those things for you.

-->

### Description

Fixes #1409

TODO:
- [x] Actually validate the JSON files at runtime
- [x] Add test cases

Future work:
- [ ] Figure out how to host these JSON files somewhere
- [ ] Add `$schema` key to newly generated `firebase.json` files

### Scenarios Tested

See unit tests

### Sample Commands

N/A

Patch:
diff --git a/package-lock.json b/package-lock.json
index 1497cbc0f..6697ff018 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2274,11 +2274,11 @@
       }
     },
     "ajv": {
-      "version": "6.10.2",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz",
-      "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==",
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
       "requires": {
-        "fast-deep-equal": "^2.0.1",
+        "fast-deep-equal": "^3.1.1",
         "fast-json-stable-stringify": "^2.0.0",
         "json-schema-traverse": "^0.4.1",
         "uri-js": "^4.2.2"
@@ -3961,6 +3961,18 @@
         "v8-compile-cache": "^2.0.3"
       },
       "dependencies": {
+        "ajv": {
+          "version": "6.12.6",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+          "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "fast-json-stable-stringify": "^2.0.0",
+            "json-schema-traverse": "^0.4.1",
+            "uri-js": "^4.2.2"
+          }
+        },
         "ansi-regex": {
           "version": "5.0.0",
           "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
@@ -4489,9 +4501,9 @@
       "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
     },
     "fast-deep-equal": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
-      "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "fast-diff": {
       "version": "1.2.0",
@@ -5488,6 +5500,19 @@
       "requires": {
         "ajv": "^6.5.5",
         "har-schema": "^2.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "6.12.6",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+          "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "fast-json-stable-stringify": "^2.0.0",
+            "json-schema-traverse": "^0.4.1",
+            "uri-js": "^4.2.2"
+          }
+        }
       }
     },
     "hard-rejection": {
@@ -6144,6 +6169,15 @@
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
       "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
     },
+    "json-stable-stringify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz",
+      "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=",
+      "dev": true,
+      "requires": {
+        "jsonify": "~0.0.0"
+      }
+    },
     "json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -6182,6 +6216,12 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "jsonify": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
+      "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
+      "dev": true
+    },
     "jsonparse": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -10255,6 +10295,166 @@
       "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==",
       "dev": true
     },
+    "typescript-json-schema": {
+      "version": "0.50.1",
+      "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.50.1.tgz",
+      "integrity": "sha512-GCof/SDoiTDl0qzPonNEV4CHyCsZEIIf+mZtlrjoD8vURCcEzEfa2deRuxYid8Znp/e27eDR7Cjg8jgGrimBCA==",
+      "dev": true,
+      "requires": {
+        "@types/json-schema": "^7.0.7",
+        "@types/node": "^14.14.33",
+        "glob": "^7.1.6",
+        "json-stable-stringify": "^1.0.1",
+        "ts-node": "^9.1.1",
+        "typescript": "~4.2.3",
+        "yargs": "^16.2.0"
+      },
+      "dependencies": {
+        "@types/json-schema": {
+          "version": "7.0.7",
+          "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
+          "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==",
+          "dev": true
+        },
+        "@types/node": {
+          "version": "14.17.3",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.3.tgz",
+          "integrity": "sha512-e6ZowgGJmTuXa3GyaPbTGxX17tnThl2aSSizrFthQ7m9uLGZBXiGhgE55cjRZTF5kjZvYn9EOPOMljdjwbflxw==",
+          "dev": true
+        },
+        "ansi-regex": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+          "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+          "dev": true
+        },
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "cliui": {
+          "version": "7.0.4",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+          "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+          "dev": true,
+          "requires": {
+            "string-width": "^4.2.0",
+            "strip-ansi": "^6.0.0",
+            "wrap-ansi": "^7.0.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "emoji-regex": {
+          "version": "8.0.0",
+          "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+          "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+          "dev": true
+        },
+        "glob": {
+          "version": "7.1.7",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+          "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+          "dev": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.0.4",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "is-fullwidth-code-point": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+          "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+          "dev": true
+        },
+        "string-width": {
+          "version": "4.2.2",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+          "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
+          "dev": true,
+          "requires": {
+            "emoji-regex": "^8.0.0",
+            "is-fullwidth-code-point": "^3.0.0",
+            "strip-ansi": "^6.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+          "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^5.0.0"
+          }
+        },
+        "typescript": {
+          "version": "4.2.4",
+          "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
+          "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==",
+          "dev": true
+        },
+        "wrap-ansi": {
+          "version": "7.0.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+          "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          }
+        },
+        "y18n": {
+          "version": "5.0.8",
+          "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+          "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+          "dev": true
+        },
+        "yargs": {
+          "version": "16.2.0",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+          "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+          "dev": true,
+          "requires": {
+            "cliui": "^7.0.2",
+            "escalade": "^3.1.1",
+            "get-caller-file": "^2.0.5",
+            "require-directory": "^2.1.1",
+            "string-width": "^4.2.0",
+            "y18n": "^5.0.5",
+            "yargs-parser": "^20.2.2"
+          }
+        },
+        "yargs-parser": {
+          "version": "20.2.7",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz",
+          "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==",
+          "dev": true
+        }
+      }
+    },
     "unbzip2-stream": {
       "version": "1.4.3",
       "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
diff --git a/package.json b/package.json
index a12a031bd..6a06c3677 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
     "format:other": "npm run lint:other -- --write",
     "format:ts": "npm run lint:ts -- --fix --quiet",
     "generate:auth-api": "ts-node scripts/gen-auth-api-spec.ts",
+    "generate:json-schema": "typescript-json-schema --strictNullChecks --required --noExtraProps src/firebaseConfig.ts FirebaseConfig > schema/firebase-config.json",
     "lint": "npm run lint:ts && npm run lint:other",
     "lint:changed-files": "ts-node ./scripts/lint-changed-files.ts",
     "lint:other": "prettier --check '**/*.{md,yaml,yml}'",
@@ -85,6 +86,7 @@
     "@types/archiver": "^5.1.0",
     "JSONStream": "^1.2.1",
     "abort-controller": "^3.0.0",
+    "ajv": "^6.12.6",
     "archiver": "^5.0.0",
     "body-parser": "^1.19.0",
     "chokidar": "^3.0.2",
@@ -205,6 +207,7 @@
     "supertest": "^3.3.0",
     "swagger2openapi": "^6.0.3",
     "ts-node": "^9.1.1",
-    "typescript": "^3.9.5"
+    "typescript": "^3.9.5",
+    "typescript-json-schema": "^0.50.1"
   }
 }
diff --git a/schema/firebase-config.json b/schema/firebase-config.json
new file mode 100644
index 000000000..e630196c2
--- /dev/null
+++ b/schema/firebase-config.json
@@ -0,0 +1,393 @@
+{
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "additionalProperties": false,
+    "properties": {
+        "database": {
+            "anyOf": [
+                {
+                    "additionalProperties": false,
+                    "properties": {
+                        "rules": {
+                            "type": "string"
+                        }
+                    },
+                    "type": "object"
+                },
+                {
+                    "items": {
+                        "additionalProperties": false,
+                        "properties": {
+                            "instance": {
+                                "type": "string"
+                            },
+                            "rules": {
+                                "type": "string"
+                            },
+                            "target": {
+                                "type": "string"
+                            }
+                        },
+                        "required": [
+                            "rules"
+                        ],
+                        "type": "object"
+                    },
+                    "type": "array"
+                }
+            ]
+        },
+        "emulators": {
+            "additionalProperties": false,
+            "properties": {
+                "auth": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "database": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "firestore": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "functions": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "hosting": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "hub": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "logging": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "pubsub": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "storage": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": "number"
+                        }
+                    },
+                    "type": "object"
+                },
+                "ui": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "enabled": {
+                            "type": "boolean"
+                        },
+                        "host": {
+                            "type": "string"
+                        },
+                        "port": {
+                            "type": [
+                                "string",
+                                "number"
+                            ]
+                        }
+                    },
+                    "type": "object"
+                }
+            },
+            "type": "object"
+        },
+        "firestore": {
+            "additionalProperties": false,
+            "properties": {
+                "indexes": {
+                    "type": "string"
+                },
+                "rules": {
+                    "type": "string"
+                }
+            },
+            "type": "object"
+        },
+        "functions": {
+            "additionalProperties": false,
+            "properties": {
+                "ignore": {
+                    "items": {
+                        "type": "string"
+                    },
+                    "type": "array"
+                },
+                "predeploy": {
+                    "items": {
+                        "type": "string"
+                    },
+                    "type": "array"
+                },
+                "source": {
+                    "type": "string"
+                }
+            },
+            "type": "object"
+        },
+        "hosting": {
+            "additionalProperties": false,
+            "properties": {
+                "appAssociation": {
+                    "type": "string"
+                },
+                "cleanUrls": {
+                    "type": "boolean"
+                },
+                "headers": {
+                    "items": {
+                        "additionalProperties": false,
+                        "properties": {
+                            "headers": {
+                                "items": {
+                                    "additionalProperties": false,
+                                    "properties": {
+                                        "key": {
+                                            "type": "string"
+                                        },
+                                        "value": {
+                                            "type": "string"
+                                        }
+                                    },
+                                    "required": [
+                                        "key",
+                                        "value"
+                                    ],
+                                    "type": "object"
+                                },
+                                "type": "array"
+                            },
+                            "source": {
+                                "type": "string"
+                            }
+                        },
+                        "required": [
+                            "headers",
+                            "source"
+                        ],
+                        "type": "object"
+                    },
+                    "type": "array"
+                },
+                "i18n": {
+                    "additionalProperties": false,
+                    "properties": {
+                        "root": {
+                            "type": "string"
+                        }
+                    },
+                    "required": [
+                        "root"
+                    ],
+                    "type": "object"
+                },
+                "ignore": {
+                    "items": {
+                        "type": "string"
+                    },
+                    "type": "array"
+                },
+                "postdeploy": {
+                    "type": "string"
+                },
+                "public": {
+                    "type": "string"
+                },
+                "redirects": {
+                    "items": {
+                        "additionalProperties": false,
+                        "properties": {
+                            "destination": {
+                                "type": "string"
+                            },
+                            "source": {
+                                "type": "string"
+                            },
+                            "type": {
+                                "type": "number"
+                            }
+                        },
+                        "required": [
+                            "destination",
+                            "source",
+                            "type"
+                        ],
+                        "type": "object"
+                    },
+                    "type": "array"
+                },
+                "rewrites": {
+                    "items": {
+                        "additionalProperties": false,
+                        "properties": {
+                            "destination": {
+                                "type": "string"
+                            },
+                            "dynamicLinks": {
+                                "type": "boolean"
+                            },
+                            "function": {
+                                "type": "string"
+                            },
+                            "run": {
+                                "additionalProperties": false,
+                                "properties": {
+                                    "region": {
+                                        "type": "string"
+                                    },
+                                    "serviceId": {
+                                        "type": "string"
+                                    }
+                                },
+                                "required": [
+                                    "serviceId"
+                                ],
+                                "type": "object"
+                            },
+                            "source": {
+                                "type": "string"
+                            }
+                        },
+                        "required": [
+                            "source"
+                        ],
+                        "type": "object"
+                    },
+                    "type": "array"
+                },
+                "trailingSlash": {
+                    "type": "boolean"
+                }
+            },
+            "required": [
+                "public"
+            ],
+            "type": "object"
+        },
+        "remoteconfig": {
+            "additionalProperties": false,
+            "properties": {
+                "template": {
+                    "type": "string"
+                }
+            },
+            "required": [
+                "template"
+            ],
+            "type": "object"
+        },
+        "storage": {
+            "anyOf": [
+                {
+                    "additionalProperties": false,
+                    "properties": {
+                        "rules": {
+                            "type": "string"
+                        }
+                    },
+                    "required": [
+                        "rules"
+                    ],
+                    "type": "object"
+                },
+                {
+                    "items": {
+                        "additionalProperties": false,
+                        "properties": {
+                            "bucket": {
+                                "type": "string"
+                            },
+                            "rules": {
+                                "type": "string"
+                            }
+                        },
+                        "required": [
+                            "bucket",
+                            "rules"
+                        ],
+                        "type": "object"
+                    },
+                    "type": "array"
+                }
+            ]
+        }
+    },
+    "type": "object"
+}
+
diff --git a/src/config.ts b/src/config.ts
index 6ea5063c9..1f998d162 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -14,6 +14,8 @@ import * as fsutils from "./fsutils";
 import { promptOnce } from "./prompt";
 import { resolveProjectPath } from "./projectPath";
 import * as utils from "./utils";
+import { getValidator, getErrorMessage } from "./firebaseConfigValidate";
+import { logger } from "./logger";
 const loadCJSON = require("./loadCJSON");
 const parseBoltRules = require("./parseBoltRules");
 
@@ -230,6 +232,20 @@ export class Config {
       try {
         const filePath = path.resolve(pd, path.basename(filename));
         const data = cjson.load(filePath);
+
+        // Validate config against JSON Schema. For now we just print these to debug
+        // logs but in a future CLI version they could be warnings and/or errors.
+        const validator = getValidator();
+        const valid = validator(data);
+        if (!valid && validator.errors) {
+          for (const e of validator.errors) {
+            // TODO: We should probably collapse these errors on the 'dataPath' property
+            //       and then pick out the most important error on each field. Otherwise
+            //       some simple mistakes can cause 2-3 errors.
+            logger.debug(getErrorMessage(e));
+          }
+        }
+
         return new Config(data, options);
       } catch (e) {
         throw new FirebaseError(`There was an error loading ${filename}:\n\n` + e.message, {
diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts
index f4bb80cf7..ac33660d2 100644
--- a/src/emulator/controller.ts
+++ b/src/emulator/controller.ts
@@ -61,7 +61,7 @@ async function getAndCheckAddress(emulator: Emulators, options: Options): Promis
   let port;
   let findAvailablePort = false;
   if (portVal) {
-    port = parseInt(portVal, 10);
+    port = parseInt(`${portVal}`, 10);
   } else {
     port = Constants.getDefaultPort(emulator);
     findAvailablePort = FIND_AVAILBLE_PORT_BY_DEFAULT[emulator];
diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts
index 475dd5aec..facef2375 100644
--- a/src/firebaseConfig.ts
+++ b/src/firebaseConfig.ts
@@ -1,6 +1,13 @@
+//
+// NOTE:
+// The contents of this file are used to generate the JSON Schema documents in
+// the schema/ directory. After changing this file you will need to run
+// 'npm run generate:json-schema' to regenerate the schema files.
+//
+
 export type DatabaseConfig =
   | {
-      rules: string;
+      rules?: string;
     }
   | {
       target?: string;
@@ -9,8 +16,8 @@ export type DatabaseConfig =
     }[];
 
 export type FirestoreConfig = {
-  rules: string;
-  indexes: string;
+  rules?: string;
+  indexes?: string;
 };
 
 export type FunctionsConfig = {
@@ -70,44 +77,44 @@ export type RemoteConfigConfig = {
 export type EmulatorsConfig = {
   auth?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   database?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   firestore?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   functions?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   hosting?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   pubsub?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   storage?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   logging?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   hub?: {
     host?: string;
-    port?: string;
+    port?: number;
   };
   ui?: {
     enabled?: boolean;
     host?: string;
-    port?: string;
+    port?: number | string;
   };
 };
 
diff --git a/src/firebaseConfigValidate.ts b/src/firebaseConfigValidate.ts
new file mode 100644
index 000000000..a2ed3291e
--- /dev/null
+++ b/src/firebaseConfigValidate.ts
@@ -0,0 +1,43 @@
+// Note: we are using ajv version 6.x because it's compatible with TypeScript
+// 3.x, if we upgrade the TS version in this project we can upgrade ajv as well.
+import { ValidateFunction, ErrorObject } from "ajv";
+import * as fs from "fs";
+import * as path from "path";
+
+const Ajv = require("ajv");
+
+const ajv = new Ajv();
+let _VALIDATOR: ValidateFunction | undefined = undefined;
+
+/**
+ * Lazily load the 'schema/firebase-config.json' file and return an AJV validation
+ * function. By doing this lazily we don't impose this I/O cost on those using
+ * the CLI as a Node module.
+ */
+export function getValidator(): ValidateFunction {
+  if (!_VALIDATOR) {
+    const schemaStr = fs.readFileSync(
+      path.resolve(__dirname, "../schema/firebase-config.json"),
+      "UTF-8"
+    );
+    const schema = JSON.parse(schemaStr);
+
+    _VALIDATOR = ajv.compile(schema);
+  }
+
+  return _VALIDATOR!;
+}
+
+export function getErrorMessage(e: ErrorObject) {
+  if (e.keyword === "additionalProperties") {
+    return `Object "${e.dataPath}" in "firebase.json" has unknown property: ${JSON.stringify(
+      e.params
+    )}`;
+  } else if (e.keyword === "required") {
+    return `Object "${
+      e.dataPath
+    }" in "firebase.json" is missing required property: ${JSON.stringify(e.params)}`;
+  } else {
+    return `Field "${e.dataPath}" in "firebase.json" is possibly invalid: ${e.message}`;
+  }
+}


Test patch:
diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml
index 1445c6cbd..0c71e6a54 100644
--- a/.github/workflows/node-test.yml
+++ b/.github/workflows/node-test.yml
@@ -124,3 +124,21 @@ jobs:
           node-version: ${{ matrix.node-version }}
       - run: npm install --package-lock-only
       - run: "git diff --exit-code -- package-lock.json || (echo 'Error: package-lock.json is changed during npm install! Please make sure to use npm >= 6.9.0 and commit package-lock.json.' && false)"
+
+  check-json-schema:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version:
+          - 12.x
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Use Node.js ${{ matrix.node-version }}
+        uses: actions/setup-node@v2
+        with:
+          node-version: ${{ matrix.node-version }}
+      - run: npm install
+      - run: npm run generate:json-schema
+      - run: "git diff --exit-code -- schema/*.json || (echo 'Error: JSON schema is changed! Please run npm run generate:json-schema and commit the results.' && false)"
diff --git a/src/test/firebaseConfigValidate.spec.ts b/src/test/firebaseConfigValidate.spec.ts
new file mode 100644
index 000000000..66ba6bffe
--- /dev/null
+++ b/src/test/firebaseConfigValidate.spec.ts
@@ -0,0 +1,115 @@
+import { expect } from "chai";
+import { getValidator } from "../firebaseConfigValidate";
+import { FirebaseConfig } from "../firebaseConfig";
+import { valid } from "semver";
+
+describe("firebaseConfigValidate", () => {
+  it("should accept a basic, valid config", () => {
+    const config: FirebaseConfig = {
+      database: {
+        rules: "myrules.json",
+      },
+      hosting: {
+        public: "public",
+      },
+      emulators: {
+        database: {
+          port: 8080,
+        },
+      },
+    };
+
+    const validator = getValidator();
+    const isValid = validator(config);
+
+    expect(isValid).to.be.true;
+  });
+
+  it("should report an extra top-level field", () => {
+    // This config has an extra 'bananas' top-level property
+    const config = {
+      database: {
+        rules: "myrules.json",
+      },
+      bananas: {},
+    };
+
+    const validator = getValidator();
+    const isValid = validator(config);
+
+    expect(isValid).to.be.false;
+    expect(validator.errors).to.exist;
+    expect(validator.errors!.length).to.eq(1);
+
+    const firstError = validator.errors![0];
+    expect(firstError.keyword).to.eq("additionalProperties");
+    expect(firstError.dataPath).to.eq("");
+    expect(firstError.params).to.deep.equal({ additionalProperty: "bananas" });
+  });
+
+  it("should report a missing required field", () => {
+    // This config is missing 'storage.rules'
+    const config = {
+      storage: {},
+    };
+
+    const validator = getValidator();
+    const isValid = validator(config);
+
+    expect(isValid).to.be.false;
+    expect(validator.errors).to.exist;
+    expect(validator.errors!.length).to.eq(3);
+
+    const [firstError, secondError, thirdError] = validator.errors!;
+
+    // Missing required param
+    expect(firstError.keyword).to.eq("required");
+    expect(firstError.dataPath).to.eq(".storage");
+    expect(firstError.params).to.deep.equal({ missingProperty: "rules" });
+
+    // Because it doesn't match the object type, we also get an "is not an array"
+    // error since JSON Schema can't tell which type it is closest to.
+    expect(secondError.keyword).to.eq("type");
+    expect(secondError.dataPath).to.eq(".storage");
+    expect(secondError.params).to.deep.equal({ type: "array" });
+
+    // Finally we get an error saying that 'storage' is not any of the known types
+    expect(thirdError.keyword).to.eq("anyOf");
+    expect(thirdError.dataPath).to.eq(".storage");
+    expect(thirdError.params).to.deep.equal({});
+  });
+
+  it("should report a field with an incorrect type", () => {
+    // This config has a number where it should have a string
+    const config = {
+      storage: {
+        rules: 1234,
+      },
+    };
+
+    const validator = getValidator();
+    const isValid = validator(config);
+
+    expect(isValid).to.be.false;
+    expect(validator.errors).to.exist;
+    expect(validator.errors!.length).to.eq(3);
+
+    const [firstError, secondError, thirdError] = validator.errors!;
+
+    // Wrong type
+    expect(firstError.keyword).to.eq("type");
+    expect(firstError.dataPath).to.eq(".storage.rules");
+    expect(firstError.params).to.deep.equal({ type: "string" });
+
+    // Because it doesn't match the object type, we also get an "is not an array"
+    // error since JSON Schema can't tell which type it is closest to.
+    expect(secondError.keyword).to.eq("type");
+    expect(secondError.dataPath).to.eq(".storage");
+    expect(secondError.params).to.deep.equal({ type: "array" });
+
+    // Finally we get an error saying that 'storage' is not any of the known types
+    expect(thirdError.keyword).to.eq("anyOf");
+    expect(thirdError.dataPath).to.eq(".storage");
+    expect(thirdError.params).to.deep.equal({});
+  });
+});