diff --git a/.eslintrc.js b/.eslintrc.js index eda9959..95f1134 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -39,18 +39,18 @@ module.exports = { 'no-unused-vars': 'off', 'no-restricted-globals': ['warn'].concat(restrictedGlobals), 'react/jsx-max-depth': 'error', + 'no-extra-boolean-cast': 'off', '@typescript-eslint/camelcase': 'off', - // ? Disable these rules for all files - 'no-undef': 'off', '@typescript-eslint/explicit-function-return-type': 'off', + // ? Disable these rules for all files... + 'no-undef': 'off', '@typescript-eslint/no-var-requires': 'off', }, overrides: [{ - // ? Enable these rules specifically for TypeScript files + // ? ... but enable these rules specifically for TypeScript files files: ['*.ts', '*.tsx'], rules: { 'no-undef': 'error', - '@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/no-var-requires': 'error', // ? Already handled by vscode '@typescript-eslint/no-unused-vars': 'off', diff --git a/API.apib b/API.apib index 7d19314..b740f4a 100644 --- a/API.apib +++ b/API.apib @@ -37,7 +37,7 @@ yourAPIkeyhere"` and you will be immediately authenticated into the system. a `HTTP 429` response along with a monotonically increasing soft ban (starting at 5 minutes). Similarly, the size of requests is strictly limited, so you must limit the amount of data you're sending. When you send a request - that is too large, it will fail with a `HTTP 413` response. + that is too large (>100KB), it will fail with a `HTTP 413` response. 2. **Do not reveal your API key to anyone** not on your own team. It is how the API identifies your team. Do not upload it to GitHub or leave it lying around @@ -99,13 +99,38 @@ long your API key and IP are banned from making further requests (in seconds). ## Pagination Endpoints that return data for multiple elections are paginated. Such endpoints -optionally accept a `limit` and `offset` parameters. `limit` tells the API how -many elections you want returned as part of your response (see below). `offset` -tells the API where to start counting elections. +optionally accept a `limit` and `after` parameters. `limit` is a number telling +the API how many elections you want returned as part of your response (see +below). `after` is a *MongoDB ObjectId* that determines which item is returned +first. + +`limit`s larger than 50 will be rejected. `after`s are special strings and not +numbers. Omitting the `after` parameter returns the first `limit<=50` elements. +`limit` must be a non-negative integer. + +For example, given the following dataset and a default limit of 3 (max 10): + +```JavaScript +[ + { item_id: 0xabc123, name: 'Item 1 name' }, + { item_id: 0xabc124, name: 'Item 2 name' }, + { item_id: 0xabc125, name: 'Item 3 name' }, + { item_id: 0xabc126, name: 'Item 4 name' }, + { item_id: 0xabc127, name: 'Item 5 name' }, +] +``` + +Paginated results: -Limits larger than 50 are reduced to 50 (the maximum). The offset numbering -starts at 0, not 1. Omitting the offset parameter returns the first `limit<=50` -elements. +`limit=0`: 0 items returned +`limit=1`: an array with only the first item is returned +`limit=5`: an array of 5 items is returned (the whole dataset!) +`limit=10`: since there are only 5 items total, same as the previous result +`limit=10, after=0xabc123`: same as the previous result +`limit=2, after=0xabc124`: returns an array with 2 items: *0xabc125* and *0xabc126* +`limit=1, after=0xabc127`: returns an array with 0 items since there is nothing after *0xabc127* +`after=0xabc124`: returns an array with the default limit of 3 items: *0xabc125* through *0xabc127* +`limit=0, after=0xabc123`: same as the very first result ## Status Codes @@ -119,7 +144,7 @@ The Elections API will issue responses with one of the following status codes: | 403 | Session is not authorized. You tried to do something you can't do. | | 404 | The resource (or endpoint) was not found. Check your syntax. | | 405 | Bad method. The endpoint does not support your request's method. | -| 413 | Your request was too large and was dropped. | +| 413 | Your request was too large and was dropped. Max body size is 100KB. | | 429 | You've been rate limited. Try your request again after a few minutes. | | 5xx | Something happened on the server that is outside your control. | @@ -169,7 +194,7 @@ at the moment. If this is a problem, please contact HSCC staff via Slack or - Are you sending properly formatted JSON as your request payload when necessary? - Elections in the system created by a specific API key are owned exclusively by that key. To put that another way: you cannot modify elections that do not belong to you. You can only view them. - Try outputting to stdout, use `console.log`, or output to some log file when API requests are made and responses received. -- Elections are returned in LIFO (descending) order by default. +- Elections are returned in FIFO (first-election-in-is-the-first-election-out) ascending/queue order. - All time data is represented as the number of milliseconds elapsed since January 1, 1970 00:00:00 UTC. ## Metadata endpoint [/meta] @@ -238,22 +263,19 @@ This endpoint deals with summary metadata about all elections in the system. "contrived": true } -## Elections endpoint [/elections{?order,limit,offset}] +## Elections endpoint [/elections{?limit,after}] This endpoint deals with all elections data currently in the system. +> Warning: An `HTTP 400` error response is returned when specifying a `limit` larger than 50 or when including `limit` or `after` query parameters in a non-GET request, both of which are not allowed. + ### List all elections in the system [GET] + Parameters - + order (optional, enum[string]) - The order elections will be returned in. - + Default: `desc` - + Members - + `asc` - + `desc` - + limit (optional, number) - Maximum number of elections returned (less than or equal to 50). + + limit (optional, number) - [optional] Maximum number of elections returned (less than or equal to 50). + Default: `15` - + offset (optional, number) - Zero-indexed offset to start counting from. - + Default: `0` + + after (optional, number) - [optional] The `election_id` of the election that exists just before the first returned election in the result list, if it exists. + + Default: `null` + Request @@ -265,7 +287,7 @@ This endpoint deals with all elections data currently in the system. + Attributes (object) + success (boolean) - If the request succeeded. Always `true` when status code is 200 and `false` or `undefined` otherwise. - + elections (array[Election]) - An array of elections. + + elections (array[Election]) - An array of elections. Empty if there are no more elections in system. + Body @@ -273,7 +295,7 @@ This endpoint deals with all elections data currently in the system. "success": true, "elections": [ { - "electionId": "adc68d36-d32f-4f68-ab8e-02ea5abee516", + "election_id": "5ec8adf06e38137ff2e5876f", "title": "My election #1", "description": "My demo election!", "options": [], @@ -284,7 +306,7 @@ This endpoint deals with all elections data currently in the system. "deleted": false }, { - "electionId": "ac166a46-8a89-4556-8fa0-7e6919a536b5", + "election_id": "5ec8adf06e38137ff2e5876e", "title": "My election #2", "description": "A custom election I made", "options": ["Option 1", "Option 2"], @@ -295,7 +317,7 @@ This endpoint deals with all elections data currently in the system. "deleted": true }, { - "electionId": "7d69233c-b380-4c14-be57-2638bd9a4081", + "election_id": "5ec8adf06e38137ff2e5876d", "title": "My election #3", "description": "An election to end all elections?", "options": ["Vanilla", "Chocolate"], @@ -308,6 +330,17 @@ This endpoint deals with all elections data currently in the system. ] } ++ Response 400 (application/json) + + + Attributes (object) + + error (string) - Why the request failed. + + + Body + + { + "error": "" + } + + Response 401 (application/json) + Attributes (object) @@ -375,13 +408,13 @@ This endpoint deals with all elections data currently in the system. + Attributes (object) + success: true (boolean) - If the request succeeded. Always `true` when status code is 200 and `false` or `undefined` otherwise. - + electionId: `ac166a46-8a89-4556-8fa0-7e6919a536b5` (string) - The GUID of the newly created election. + + election_id: `ac166a46-8a89-4556-8fa0-7e6919a536b5` (string) - The unique MongoDB id of the newly created election. + Body { "success": true, - "electionId": "ac166a46-8a89-4556-8fa0-7e6919a536b5" + "election_id": "5ec8adf06e38137ff2e5876c" } + Response 400 (application/json) @@ -432,12 +465,12 @@ This endpoint deals with all elections data currently in the system. "contrived": true } -## Election endpoint [/election/{electionId}] +## Election endpoint [/election/{election_id}] -This endpoint returns an expanded data object describing the election specified via **electionId**. +This endpoint returns an expanded data object describing the election specified via **election_id**. + Parameters - + electionId (string) - The GUID of the election targeted by some operation. + + election_id (string) - The unique MongoDB id of the election targeted by some operation. ### Return data about an election [GET] @@ -456,7 +489,7 @@ This endpoint returns an expanded data object describing the election specified { "success": true, - "electionId": "ac166a46-8a89-4556-8fa0-7e6919a536b5", + "election_id": "5ec8adf06e38137ff2e5876b", "title": "My election #2", "description": "A custom election I made", "options": ["Option 1", "Option 2"], @@ -663,12 +696,12 @@ This endpoint returns an expanded data object describing the election specified "contrived": true } -## Voters endpoint [/election/{electionId}/voters] +## Voters endpoint [/election/{election_id}/voters] This endpoint deals with an election's mappings between voter IDs and rankings (votes). + Parameters - + electionId (string) - The GUID of the election targeted by some operation. + + election_id (string) - The unique MongoDB id of the election targeted by some operation. ### Return an election's mapping of voters to rankings [GET] @@ -682,17 +715,17 @@ This endpoint deals with an election's mappings between voter IDs and rankings ( + Attributes (object) + success: true (boolean) - If the request succeeded. Always `true` when status code is 200 and `false` or `undefined` otherwise. - + votes: `{"voterId":"1","ranking":["Biden", "Sanders"]}`,`{"voterId":"2","ranking":["Sanders", "Biden"]}` (array[Vote]) - Array of objects each mapping a app-local voter ID to an election's option. + + votes: `{"voter_id":"1","ranking":["Biden", "Sanders"]}`,`{"voter_id":"2","ranking":["Sanders", "Biden"]}` (array[Vote]) - Array of objects each mapping a app-local voter ID to an election's option. + Body { "success": true, "votes": [ - {"voterId":"1","ranking":["Biden", "Warren", "Sanders"]}, - {"voterId":"2","ranking":["Sanders", "Warren", "Biden"]}, - {"voterId":"3","ranking":["Warren", "Sanders", "Biden"]}, - {"voterId":"4","ranking":["Warren", "Biden", "Sanders"]} + {"voter_id":"1","ranking":["Biden", "Warren", "Sanders"]}, + {"voter_id":"2","ranking":["Sanders", "Warren", "Biden"]}, + {"voter_id":"3","ranking":["Warren", "Sanders", "Biden"]}, + {"voter_id":"4","ranking":["Warren", "Biden", "Sanders"]} ] } @@ -745,9 +778,9 @@ This endpoint deals with an election's mappings between voter IDs and rankings ( { "votes": [ - {"voterId": "voter1@email.com", "ranking": ["Vanilla", "Chocolate"]}, - {"voterId": "voter2@email.com", "ranking": ["Chocolate", "Vanilla"]}, - {"voterId": "voter3@email.com", "ranking": ["Vanilla", "Chocolate"]} + {"voter_id": "voter1@email.com", "ranking": ["Vanilla", "Chocolate"]}, + {"voter_id": "voter2@email.com", "ranking": ["Chocolate", "Vanilla"]}, + {"voter_id": "voter3@email.com", "ranking": ["Vanilla", "Chocolate"]} ] } @@ -825,7 +858,7 @@ This endpoint deals with an election's mappings between voter IDs and rankings ( ### Election (object) -+ electionId: `afdf5513-ab35-4fbd-ad52-c22dbc899d31` (string) - GUID representing the election. Generated automatically by the server. ++ election_id: `5ec8adf06e38137ff2e58769` (string) - unique MongoDB id representing the election. Generated automatically by the server. + title: My Election (string) - Title of the election. + description: Election description (string) - Description of the election. + options: `"Biden"`,`"Sanders"` (array[string]) - Array of options voters are allowed to select from. @@ -837,5 +870,5 @@ This endpoint deals with an election's mappings between voter IDs and rankings ( ### Vote (object) -+ voterId: myemail@me.com (string) - A unique (relative to your app) identifier representing a voter in your own system. This can be whatever string you'd like it to be. -+ ranking: `"Biden"`,`"Sanders"` (array[string]) - Whichever ranked choices the voter represented by `voterId` made when casting their vote. From left to right, the order of the array represents the ranking users' chose from most preferred to least preferred. ++ voter_id: myemail@me.com (string) - A unique (relative to your app) identifier representing a voter in your own system. This can be whatever string you'd like it to be. ++ ranking: `"Biden"`,`"Sanders"` (array[string]) - Whichever ranked choices the voter represented by `voter_id` made when casting their vote. From left to right, the order of the array represents the ranking users' chose from most preferred to least preferred. diff --git a/config/next.config.ts b/config/next.config.ts index 917ea68..41b5bda 100644 --- a/config/next.config.ts +++ b/config/next.config.ts @@ -23,7 +23,7 @@ module.exports = (): object => { // ? Webpack configuration // ! Note that the webpack configuration is executed twice: once // ! server-side and once client-side! - webpack: (config: Configuration, { isServer }: { isServer: boolean }) => { + webpack: (config: Configuration) => { // ? These are aliases that can be used during JS import calls // ! Note that you must also change these same aliases in tsconfig.json // ! Note that you must also change these same aliases in package.json (jest) @@ -33,12 +33,22 @@ module.exports = (): object => { multiverse: paths.multiverse, }); - if(isServer) { - // ? Add referenced environment variables defined in .env to bundle - config.plugins && config.plugins.push(new DotenvWebpackPlugin()); - } - return config; + }, + + // ? Select some environment variables defined in .env to push to the + // ? client. + // !! DO NOT PUT ANY SECRET ENVIRONMENT VARIABLES HERE !! + env: { + MAX_LIMIT: process.env.MAX_LIMIT, + LIMIT_OVERRIDE: process.env.LIMIT_OVERRIDE, + DISABLE_RATE_LIMITS: process.env.DISABLE_RATE_LIMITS, + LOCKOUT_ALL_KEYS: process.env.LOCKOUT_ALL_KEYS, + DISALLOW_WRITES: process.env.DISALLOW_WRITES, + REQUESTS_PER_CONTRIVED_ERROR: process.env.REQUESTS_PER_CONTRIVED_ERROR, + MAX_OPTIONS_PER_ELECTION: process.env.MAX_OPTIONS_PER_ELECTION, + MAX_RANKINGS_PER_ELECTION: process.env.MAX_RANKINGS_PER_ELECTION, + MAX_CONTENT_LENGTH_BYTES: process.env.MAX_CONTENT_LENGTH_BYTES, } }); }; diff --git a/dist.env b/dist.env index 8fd536c..5b9abf1 100644 --- a/dist.env +++ b/dist.env @@ -1,4 +1,8 @@ -# If defined, Next's bundle(s) will be analyzed and report files generated +# When adding new environment variables, make sure to update +# expectedEnvVariables in package.json if said variables should definitely be +# defined. + +# If !false, Next's bundle(s) will be analyzed and report files generated ANALYZE=false # This is the default NODE_ENV setting for the application. Recognized values: @@ -10,4 +14,41 @@ ANALYZE=false NODE_ENV=development # MongoDB connect URI +# Specify auth credentials if necessary +# MUST SPECIFY A DATABASE AT THE END! i.e. mongodb://.../your-database-here MONGODB_URI= + +# Optional (hopefully local) MongoDB connect URI used for Jest tests +# Specify auth credentials if necessary +# MUST SPECIFY A DATABASE AT THE END! i.e. mongodb://.../your-database-here +MONGODB_TEST_URI= + +# Determines the maximum number of items returned by paginated endpoints +MAX_LIMIT=50 + +# If !0, all paginated endpoints will return LIMIT_OVERRIDE items regardless of +# params provided by end user +LIMIT_OVERRIDE=0 + +# If !false, all rate limits and exponential soft banning will be disabled +DISABLE_RATE_LIMITS=false + +# If !false, no one will be able to use the API +LOCKOUT_ALL_KEYS=false + +# If !false, the API will behave in a "read-only" fashion, disallowing changes +DISALLOW_WRITES=false + +# Every Nth request will be be cancelled and an HTTP 555 response returned. Set +# to 0 to disable +REQUESTS_PER_CONTRIVED_ERROR=10 + +# Maximum allowed number of options per election +MAX_OPTIONS_PER_ELECTION=15 + +# Maximum allowed number of voter rankings per election +MAX_RANKINGS_PER_ELECTION=1000 + +# Maximum allowed size of a request body and Content-Length header in bytes. +# Should be a string like 1kb, 1mb, 500b +MAX_CONTENT_LENGTH_BYTES=100kb diff --git a/gulpfile.js b/gulpfile.js index 626d416..f94995e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -22,11 +22,6 @@ const { } = require('./src/dev-utils'); const regenTargets = [`config/*.[jt]s`]; -const CLI_BANNER = `/* eslint-disable */\n/** -* !!! DO NOT EDIT THIS FILE DIRECTLY !!! -* ! This file has been generated automatically. See the config/*.[jt]s versions -* ! of this file to make permanent modifications! -*/\n\n`; const checkEnv = async () => populateEnv(); @@ -51,4 +46,4 @@ const regenerate = () => { exports.regenerate = regenerate; regenerate.description = 'Invokes babel on the files in config, transpiling them into their project root versions'; -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImNvbmZpZy9ndWxwZmlsZS50cyJdLCJuYW1lcyI6WyJwb3B1bGF0ZUVudiIsInJlcXVpcmUiLCJyZWdlblRhcmdldHMiLCJDTElfQkFOTkVSIiwiY2hlY2tFbnYiLCJkZXNjcmlwdGlvbiIsInJlZ2VuZXJhdGUiLCJqb2luIiwicHJvY2VzcyIsImVudiIsIkJBQkVMX0VOViIsImd1bHAiLCJzcmMiLCJwaXBlIiwiZmlsZSIsImNvbnRlbnRzIiwiQnVmZmVyIiwiZnJvbSIsInRvU3RyaW5nIiwiZmlsZW5hbWUiLCJwYXRoIiwic291cmNlRmlsZU5hbWUiLCJfX2Rpcm5hbWUiLCJjb2RlIiwibmFtZSIsImJhc2VuYW1lIiwiZGVzdCJdLCJtYXBwaW5ncyI6Ijs7Ozs7OztBQU9BOztBQUNBOztBQUNBOztBQUNBOztBQUNBOzs7O0FBS0EsTUFBTTtBQUFFQSxFQUFBQTtBQUFGLElBQWtCQyxPQUFPLENBQUMsaUJBQUQsQ0FBL0I7O0FBRUEsTUFBTUMsWUFBWSxHQUFHLENBQ2hCLGdCQURnQixDQUFyQjtBQUlBLE1BQU1DLFVBQVUsR0FBSTs7OztPQUFwQjs7QUFRTyxNQUFNQyxRQUFRLEdBQUcsWUFBMkJKLFdBQVcsRUFBdkQ7OztBQUVQSSxRQUFRLENBQUNDLFdBQVQsR0FBd0IsNkVBQUQsR0FDaEIsZ0RBRFA7O0FBU08sTUFBTUMsVUFBVSxHQUFHLE1BQThCO0FBQ3BETixFQUFBQSxXQUFXO0FBRVgseUJBQUssMEJBQXlCRSxZQUFZLENBQUNLLElBQWIsQ0FBa0IsS0FBbEIsQ0FBeUIsR0FBdkQ7QUFFQUMsRUFBQUEsT0FBTyxDQUFDQyxHQUFSLENBQVlDLFNBQVosR0FBd0IsV0FBeEI7QUFFQSxTQUFPQyxjQUFLQyxHQUFMLENBQVNWLFlBQVQsRUFDRlcsSUFERSxDQUNHLHNCQUFJQyxJQUFJLElBQUk7QUFBQTs7QUFDZEEsSUFBQUEsSUFBSSxDQUFDQyxRQUFMLEdBQWdCRCxJQUFJLENBQUNDLFFBQUwsSUFBaUJDLE1BQU0sQ0FBQ0MsSUFBUCxDQUFZLG9DQUFNSCxJQUFJLENBQUNDLFFBQUwsQ0FBY0csUUFBZCxDQUF1QixNQUF2QixDQUFOLEVBQXNDO0FBQy9FQyxNQUFBQSxRQUFRLEVBQUVMLElBQUksQ0FBQ00sSUFEZ0U7QUFFL0VDLE1BQUFBLGNBQWMsRUFBRSxvQkFBSUMsU0FBSixFQUFlUixJQUFJLENBQUNNLElBQXBCO0FBRitELEtBQXRDLG1EQUd6Q0csSUFIeUMsS0FHakMsRUFIcUIsQ0FBakM7QUFLQSxVQUFNQyxJQUFJLEdBQUcsb0JBQVNWLElBQUksQ0FBQ1csUUFBZCxFQUF3QixLQUF4QixDQUFiO0FBQ0FYLElBQUFBLElBQUksQ0FBQ1csUUFBTCxHQUFnQkQsSUFBSSxJQUFJVixJQUFJLENBQUNXLFFBQWIsR0FBd0JELElBQXhCLEdBQWdDLEdBQUVBLElBQUssS0FBdkQ7QUFDSCxHQVJLLENBREgsRUFVRlgsSUFWRSxDQVVHRixjQUFLZSxJQUFMLENBQVUsR0FBVixDQVZILENBQVA7QUFXSCxDQWxCTTs7O0FBb0JQcEIsVUFBVSxDQUFDRCxXQUFYLEdBQXlCLHlGQUF6QiIsInNvdXJjZXNDb250ZW50IjpbIi8vID8gVG8gcmVnZW5lcmF0ZSB0aGlzIGZpbGUgKGkuZS4gaWYgeW91IGNoYW5nZWQgaXQgYW5kIHdhbnQgeW91ciBjaGFuZ2VzIHRvXG4vLyA/IGJlIHBlcm1hbmVudCksIGNhbGwgYG5wbSBydW4gcmVnZW5lcmF0ZWAgYWZ0ZXJ3YXJkc1xuXG4vLyAhIEJlIHN1cmUgdGhhdCB0YXNrcyBleHBlY3RlZCB0byBydW4gb24gbnBtIGluc3RhbGwgKG1hcmtlZCBAZGVwZW5kZW50KSBoYXZlXG4vLyAhIGFsbCByZXF1aXJlZCBwYWNrYWdlcyBsaXN0ZWQgdW5kZXIgXCJkZXBlbmRlbmNpZXNcIiBpbnN0ZWFkIG9mXG4vLyAhIFwiZGV2RGVwZW5kZW5jaWVzXCIgaW4gdGhpcyBwcm9qZWN0J3MgcGFja2FnZS5qc29uXG5cbmltcG9ydCB7IHJlbGF0aXZlIGFzIHJlbCwgYmFzZW5hbWUgfSBmcm9tICdwYXRoJ1xuaW1wb3J0IHsgdHJhbnNmb3JtU3luYyBhcyBiYWJlbCB9IGZyb20gJ0BiYWJlbC9jb3JlJ1xuaW1wb3J0IGd1bHAgZnJvbSAnZ3VscCdcbmltcG9ydCB0YXAgZnJvbSAnZ3VscC10YXAnXG5pbXBvcnQgbG9nIGZyb20gJ2ZhbmN5LWxvZydcblxuLy8gPyBOb3QgdXNpbmcgRVM2L1RTIGltcG9ydCBzeW50YXggaGVyZSBiZWNhdXNlIGRldi11dGlscyBoYXMgc3BlY2lhbFxuLy8gPyBjaXJjdW1zdGFuY2VzXG4vLyBlc2xpbnQtZGlzYWJsZS1uZXh0LWxpbmUgaW1wb3J0L25vLXVucmVzb2x2ZWQsIEB0eXBlc2NyaXB0LWVzbGludC9uby12YXItcmVxdWlyZXNcbmNvbnN0IHsgcG9wdWxhdGVFbnYgfSA9IHJlcXVpcmUoJy4vc3JjL2Rldi11dGlscycpO1xuXG5jb25zdCByZWdlblRhcmdldHMgPSBbXG4gICAgYGNvbmZpZy8qLltqdF1zYFxuXTtcblxuY29uc3QgQ0xJX0JBTk5FUiA9IGAvKiBlc2xpbnQtZGlzYWJsZSAqL1xcbi8qKlxuKiAhISEgRE8gTk9UIEVESVQgVEhJUyBGSUxFIERJUkVDVExZICEhIVxuKiAhIFRoaXMgZmlsZSBoYXMgYmVlbiBnZW5lcmF0ZWQgYXV0b21hdGljYWxseS4gU2VlIHRoZSBjb25maWcvKi5banRdcyB2ZXJzaW9uc1xuKiAhIG9mIHRoaXMgZmlsZSB0byBtYWtlIHBlcm1hbmVudCBtb2RpZmljYXRpb25zIVxuKi9cXG5cXG5gO1xuXG4vLyAqIENIRUNLRU5WXG5cbmV4cG9ydCBjb25zdCBjaGVja0VudiA9IGFzeW5jICgpOiBQcm9taXNlPHZvaWQ+ID0+IHBvcHVsYXRlRW52KCk7XG5cbmNoZWNrRW52LmRlc2NyaXB0aW9uID0gYFRocm93cyBhbiBlcnJvciBpZiBhbnkgZXhwZWN0ZWQgZW52aXJvbm1lbnQgdmFyaWFibGVzIGFyZSBub3QgcHJvcGVybHkgc2V0IGBcbiAgICArIGAoc2VlIGV4cGVjdGVkRW52VmFyaWFibGVzIGtleSBpbiBwYWNrYWdlLmpzb24pYDtcblxuLy8gKiBSRUdFTkVSQVRFXG5cbi8vID8gSWYgeW91IGNoYW5nZSB0aGlzIGZ1bmN0aW9uLCBydW4gYG5wbSBydW4gcmVnZW5lcmF0ZWAgdHdpY2U6IG9uY2UgdG9cbi8vID8gY29tcGlsZSB0aGlzIG5ldyBmdW5jdGlvbiBhbmQgb25jZSBhZ2FpbiB0byBjb21waWxlIGl0c2VsZiB3aXRoIHRoZSBuZXdseVxuLy8gPyBjb21waWxlZCBsb2dpYy4gSWYgdGhlcmUgaXMgYW4gZXJyb3IgdGhhdCBwcmV2ZW50cyByZWdlbmVyYXRpb24sIHlvdSBjYW5cbi8vID8gcnVuIGBucG0gcnVuIGdlbmVyYXRlYCB0aGVuIGBucG0gcnVuIHJlZ2VuZXJhdGVgIGluc3RlYWQuXG5leHBvcnQgY29uc3QgcmVnZW5lcmF0ZSA9ICgpOiBOb2RlSlMuUmVhZFdyaXRlU3RyZWFtID0+IHtcbiAgICBwb3B1bGF0ZUVudigpO1xuXG4gICAgbG9nKGBSZWdlbmVyYXRpbmcgdGFyZ2V0czogXCIke3JlZ2VuVGFyZ2V0cy5qb2luKCdcIiBcIicpfVwiYCk7XG5cbiAgICBwcm9jZXNzLmVudi5CQUJFTF9FTlYgPSAnZ2VuZXJhdG9yJztcblxuICAgIHJldHVybiBndWxwLnNyYyhyZWdlblRhcmdldHMpXG4gICAgICAgIC5waXBlKHRhcChmaWxlID0+IHtcbiAgICAgICAgICAgIGZpbGUuY29udGVudHMgPSBmaWxlLmNvbnRlbnRzICYmIEJ1ZmZlci5mcm9tKGJhYmVsKGZpbGUuY29udGVudHMudG9TdHJpbmcoJ3V0ZjgnKSwge1xuICAgICAgICAgICAgICAgIGZpbGVuYW1lOiBmaWxlLnBhdGgsXG4gICAgICAgICAgICAgICAgc291cmNlRmlsZU5hbWU6IHJlbChfX2Rpcm5hbWUsIGZpbGUucGF0aClcbiAgICAgICAgICAgIH0pPy5jb2RlIHx8ICcnKTtcblxuICAgICAgICAgICAgY29uc3QgbmFtZSA9IGJhc2VuYW1lKGZpbGUuYmFzZW5hbWUsICcudHMnKTtcbiAgICAgICAgICAgIGZpbGUuYmFzZW5hbWUgPSBuYW1lID09IGZpbGUuYmFzZW5hbWUgPyBuYW1lIDogYCR7bmFtZX0uanNgO1xuICAgICAgICB9KSlcbiAgICAgICAgLnBpcGUoZ3VscC5kZXN0KCcuJykpO1xufTtcblxucmVnZW5lcmF0ZS5kZXNjcmlwdGlvbiA9ICdJbnZva2VzIGJhYmVsIG9uIHRoZSBmaWxlcyBpbiBjb25maWcsIHRyYW5zcGlsaW5nIHRoZW0gaW50byB0aGVpciBwcm9qZWN0IHJvb3QgdmVyc2lvbnMnO1xuIl19 \ No newline at end of file +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImNvbmZpZy9ndWxwZmlsZS50cyJdLCJuYW1lcyI6WyJwb3B1bGF0ZUVudiIsInJlcXVpcmUiLCJyZWdlblRhcmdldHMiLCJjaGVja0VudiIsImRlc2NyaXB0aW9uIiwicmVnZW5lcmF0ZSIsImpvaW4iLCJwcm9jZXNzIiwiZW52IiwiQkFCRUxfRU5WIiwiZ3VscCIsInNyYyIsInBpcGUiLCJmaWxlIiwiY29udGVudHMiLCJCdWZmZXIiLCJmcm9tIiwidG9TdHJpbmciLCJmaWxlbmFtZSIsInBhdGgiLCJzb3VyY2VGaWxlTmFtZSIsIl9fZGlybmFtZSIsImNvZGUiLCJuYW1lIiwiYmFzZW5hbWUiLCJkZXN0Il0sIm1hcHBpbmdzIjoiOzs7Ozs7O0FBT0E7O0FBQ0E7O0FBQ0E7O0FBQ0E7O0FBQ0E7Ozs7QUFLQSxNQUFNO0FBQUVBLEVBQUFBO0FBQUYsSUFBa0JDLE9BQU8sQ0FBQyxpQkFBRCxDQUEvQjs7QUFFQSxNQUFNQyxZQUFZLEdBQUcsQ0FDaEIsZ0JBRGdCLENBQXJCOztBQU1PLE1BQU1DLFFBQVEsR0FBRyxZQUEyQkgsV0FBVyxFQUF2RDs7O0FBRVBHLFFBQVEsQ0FBQ0MsV0FBVCxHQUF3Qiw2RUFBRCxHQUNoQixnREFEUDs7QUFTTyxNQUFNQyxVQUFVLEdBQUcsTUFBOEI7QUFDcERMLEVBQUFBLFdBQVc7QUFFWCx5QkFBSywwQkFBeUJFLFlBQVksQ0FBQ0ksSUFBYixDQUFrQixLQUFsQixDQUF5QixHQUF2RDtBQUVBQyxFQUFBQSxPQUFPLENBQUNDLEdBQVIsQ0FBWUMsU0FBWixHQUF3QixXQUF4QjtBQUVBLFNBQU9DLGNBQUtDLEdBQUwsQ0FBU1QsWUFBVCxFQUNGVSxJQURFLENBQ0csc0JBQUlDLElBQUksSUFBSTtBQUFBOztBQUNkQSxJQUFBQSxJQUFJLENBQUNDLFFBQUwsR0FBZ0JELElBQUksQ0FBQ0MsUUFBTCxJQUFpQkMsTUFBTSxDQUFDQyxJQUFQLENBQVksb0NBQU1ILElBQUksQ0FBQ0MsUUFBTCxDQUFjRyxRQUFkLENBQXVCLE1BQXZCLENBQU4sRUFBc0M7QUFDL0VDLE1BQUFBLFFBQVEsRUFBRUwsSUFBSSxDQUFDTSxJQURnRTtBQUUvRUMsTUFBQUEsY0FBYyxFQUFFLG9CQUFJQyxTQUFKLEVBQWVSLElBQUksQ0FBQ00sSUFBcEI7QUFGK0QsS0FBdEMsbURBR3pDRyxJQUh5QyxLQUdqQyxFQUhxQixDQUFqQztBQUtBLFVBQU1DLElBQUksR0FBRyxvQkFBU1YsSUFBSSxDQUFDVyxRQUFkLEVBQXdCLEtBQXhCLENBQWI7QUFDQVgsSUFBQUEsSUFBSSxDQUFDVyxRQUFMLEdBQWdCRCxJQUFJLElBQUlWLElBQUksQ0FBQ1csUUFBYixHQUF3QkQsSUFBeEIsR0FBZ0MsR0FBRUEsSUFBSyxLQUF2RDtBQUNILEdBUkssQ0FESCxFQVVGWCxJQVZFLENBVUdGLGNBQUtlLElBQUwsQ0FBVSxHQUFWLENBVkgsQ0FBUDtBQVdILENBbEJNOzs7QUFvQlBwQixVQUFVLENBQUNELFdBQVgsR0FBeUIseUZBQXpCIiwic291cmNlc0NvbnRlbnQiOlsiLy8gPyBUbyByZWdlbmVyYXRlIHRoaXMgZmlsZSAoaS5lLiBpZiB5b3UgY2hhbmdlZCBpdCBhbmQgd2FudCB5b3VyIGNoYW5nZXMgdG9cbi8vID8gYmUgcGVybWFuZW50KSwgY2FsbCBgbnBtIHJ1biByZWdlbmVyYXRlYCBhZnRlcndhcmRzXG5cbi8vICEgQmUgc3VyZSB0aGF0IHRhc2tzIGV4cGVjdGVkIHRvIHJ1biBvbiBucG0gaW5zdGFsbCAobWFya2VkIEBkZXBlbmRlbnQpIGhhdmVcbi8vICEgYWxsIHJlcXVpcmVkIHBhY2thZ2VzIGxpc3RlZCB1bmRlciBcImRlcGVuZGVuY2llc1wiIGluc3RlYWQgb2Zcbi8vICEgXCJkZXZEZXBlbmRlbmNpZXNcIiBpbiB0aGlzIHByb2plY3QncyBwYWNrYWdlLmpzb25cblxuaW1wb3J0IHsgcmVsYXRpdmUgYXMgcmVsLCBiYXNlbmFtZSB9IGZyb20gJ3BhdGgnXG5pbXBvcnQgeyB0cmFuc2Zvcm1TeW5jIGFzIGJhYmVsIH0gZnJvbSAnQGJhYmVsL2NvcmUnXG5pbXBvcnQgZ3VscCBmcm9tICdndWxwJ1xuaW1wb3J0IHRhcCBmcm9tICdndWxwLXRhcCdcbmltcG9ydCBsb2cgZnJvbSAnZmFuY3ktbG9nJ1xuXG4vLyA/IE5vdCB1c2luZyBFUzYvVFMgaW1wb3J0IHN5bnRheCBoZXJlIGJlY2F1c2UgZGV2LXV0aWxzIGhhcyBzcGVjaWFsXG4vLyA/IGNpcmN1bXN0YW5jZXNcbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBpbXBvcnQvbm8tdW5yZXNvbHZlZCwgQHR5cGVzY3JpcHQtZXNsaW50L25vLXZhci1yZXF1aXJlc1xuY29uc3QgeyBwb3B1bGF0ZUVudiB9ID0gcmVxdWlyZSgnLi9zcmMvZGV2LXV0aWxzJyk7XG5cbmNvbnN0IHJlZ2VuVGFyZ2V0cyA9IFtcbiAgICBgY29uZmlnLyouW2p0XXNgXG5dO1xuXG4vLyAqIENIRUNLRU5WXG5cbmV4cG9ydCBjb25zdCBjaGVja0VudiA9IGFzeW5jICgpOiBQcm9taXNlPHZvaWQ+ID0+IHBvcHVsYXRlRW52KCk7XG5cbmNoZWNrRW52LmRlc2NyaXB0aW9uID0gYFRocm93cyBhbiBlcnJvciBpZiBhbnkgZXhwZWN0ZWQgZW52aXJvbm1lbnQgdmFyaWFibGVzIGFyZSBub3QgcHJvcGVybHkgc2V0IGBcbiAgICArIGAoc2VlIGV4cGVjdGVkRW52VmFyaWFibGVzIGtleSBpbiBwYWNrYWdlLmpzb24pYDtcblxuLy8gKiBSRUdFTkVSQVRFXG5cbi8vID8gSWYgeW91IGNoYW5nZSB0aGlzIGZ1bmN0aW9uLCBydW4gYG5wbSBydW4gcmVnZW5lcmF0ZWAgdHdpY2U6IG9uY2UgdG9cbi8vID8gY29tcGlsZSB0aGlzIG5ldyBmdW5jdGlvbiBhbmQgb25jZSBhZ2FpbiB0byBjb21waWxlIGl0c2VsZiB3aXRoIHRoZSBuZXdseVxuLy8gPyBjb21waWxlZCBsb2dpYy4gSWYgdGhlcmUgaXMgYW4gZXJyb3IgdGhhdCBwcmV2ZW50cyByZWdlbmVyYXRpb24sIHlvdSBjYW5cbi8vID8gcnVuIGBucG0gcnVuIGdlbmVyYXRlYCB0aGVuIGBucG0gcnVuIHJlZ2VuZXJhdGVgIGluc3RlYWQuXG5leHBvcnQgY29uc3QgcmVnZW5lcmF0ZSA9ICgpOiBOb2RlSlMuUmVhZFdyaXRlU3RyZWFtID0+IHtcbiAgICBwb3B1bGF0ZUVudigpO1xuXG4gICAgbG9nKGBSZWdlbmVyYXRpbmcgdGFyZ2V0czogXCIke3JlZ2VuVGFyZ2V0cy5qb2luKCdcIiBcIicpfVwiYCk7XG5cbiAgICBwcm9jZXNzLmVudi5CQUJFTF9FTlYgPSAnZ2VuZXJhdG9yJztcblxuICAgIHJldHVybiBndWxwLnNyYyhyZWdlblRhcmdldHMpXG4gICAgICAgIC5waXBlKHRhcChmaWxlID0+IHtcbiAgICAgICAgICAgIGZpbGUuY29udGVudHMgPSBmaWxlLmNvbnRlbnRzICYmIEJ1ZmZlci5mcm9tKGJhYmVsKGZpbGUuY29udGVudHMudG9TdHJpbmcoJ3V0ZjgnKSwge1xuICAgICAgICAgICAgICAgIGZpbGVuYW1lOiBmaWxlLnBhdGgsXG4gICAgICAgICAgICAgICAgc291cmNlRmlsZU5hbWU6IHJlbChfX2Rpcm5hbWUsIGZpbGUucGF0aClcbiAgICAgICAgICAgIH0pPy5jb2RlIHx8ICcnKTtcblxuICAgICAgICAgICAgY29uc3QgbmFtZSA9IGJhc2VuYW1lKGZpbGUuYmFzZW5hbWUsICcudHMnKTtcbiAgICAgICAgICAgIGZpbGUuYmFzZW5hbWUgPSBuYW1lID09IGZpbGUuYmFzZW5hbWUgPyBuYW1lIDogYCR7bmFtZX0uanNgO1xuICAgICAgICB9KSlcbiAgICAgICAgLnBpcGUoZ3VscC5kZXN0KCcuJykpO1xufTtcblxucmVnZW5lcmF0ZS5kZXNjcmlwdGlvbiA9ICdJbnZva2VzIGJhYmVsIG9uIHRoZSBmaWxlcyBpbiBjb25maWcsIHRyYW5zcGlsaW5nIHRoZW0gaW50byB0aGVpciBwcm9qZWN0IHJvb3QgdmVyc2lvbnMnO1xuIl19 \ No newline at end of file diff --git a/lib/is-server-side.ts b/lib/is-server-side.ts new file mode 100644 index 0000000..62d793f --- /dev/null +++ b/lib/is-server-side.ts @@ -0,0 +1 @@ +export default () => typeof window == 'undefined'; diff --git a/lib/respond.ts b/lib/respond.ts new file mode 100644 index 0000000..c83e2d6 --- /dev/null +++ b/lib/respond.ts @@ -0,0 +1,81 @@ +// TODO: turn this into an @ergodark npm package along with the types +// TODO: also, GenericObject should go into @ergodark/types along with other +// TODO: shared types + +import type { NextApiResponse } from 'next' +import type { HttpStatusCode, GenericObject, SuccessJsonResponse, ErrorJsonResponse } from 'types/global' + +export function sendGenericHttpResponse(res: NextApiResponse, statusCode: HttpStatusCode, responseJson: GenericObject) { + res.status(statusCode).send(responseJson); +} + +export function sendHttpSuccessResponse(res: NextApiResponse, statusCode: HttpStatusCode | undefined, responseJson: GenericObject) { + const json: SuccessJsonResponse = { ...responseJson, success: true }; + sendGenericHttpResponse(res, statusCode || 200, json); + return json; +} + +export function sendHttpErrorResponse(res: NextApiResponse, statusCode: HttpStatusCode, responseJson: ErrorJsonResponse) { + sendGenericHttpResponse(res, statusCode, responseJson); + return responseJson; +} + +export function sendHttpOk(res: NextApiResponse, responseJson: GenericObject) { + sendHttpSuccessResponse(res, undefined, responseJson); +} + +export function sendHttpBadRequest(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 400, { + error: 'request was malformed or otherwise bad', + ...responseJson + }); +} + +export function sendHttpUnauthenticated(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 401, { + error: 'session is not authenticated', + ...responseJson + }); +} + +export function sendHttpUnauthorized(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 403, { + error: 'session is not authorized', + ...responseJson + }); +} + +export function sendHttpNotFound(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 404, { + error: 'resource was not found', + ...responseJson + }); +} + +export function sendHttpBadMethod(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 405, { + error: 'bad method', + ...responseJson + }); +} + +export function sendHttpTooLarge(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 413, { + error: 'request body is too large', + ...responseJson + }); +} + +export function sendHttpRateLimited(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 429, { + error: 'session is rate limited', + ...responseJson + }); +} + +export function sendHttpError(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 500, { + error: '🤯 something unexpected happened on our end 🤯', + ...responseJson + }); +} diff --git a/lib/simple-auth-session.ts b/lib/simple-auth-session.ts deleted file mode 100644 index 50b51e3..0000000 --- a/lib/simple-auth-session.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { applySession } from 'next-session' -import findPackageJSON from 'find-package-json' - -import type { NextApiRequest, NextApiResponse } from 'next' - -export type NextParamsRR = { - req: NextApiRequest; - res: NextApiResponse; -}; - -export type NextSessionRequest = NextApiRequest & { session: { __sa: { authed: boolean }}}; -export type NextParamsRRWithSession = NextParamsRR & { req: NextSessionRequest }; - -// TODO: document all of this -let sessionOptions: object | null = null; - -export function getGlobalSessionOptions(): typeof sessionOptions { - return sessionOptions = sessionOptions || findPackageJSON(process.cwd()).next()?.value?.sessionOptions || {}; -} - -export async function sessionStart(args: NextParamsRR): Promise { - await applySession(args.req, args.res, getGlobalSessionOptions()); -} - -const setup = async (args: NextParamsRRWithSession): Promise => { - !args.req.session && await sessionStart(args); - // TODO: use a symbol type for authed instead of relying on __sa.whatever - args.req.session.__sa = args.req.session.__sa || { authed: false }; -}; - -export async function isAuthed(args: NextParamsRRWithSession): Promise { - await setup(args); - return !!args.req.session.__sa.authed; -} - -export async function auth(args: NextParamsRRWithSession): Promise { - await setup(args); - args.req.session.__sa.authed = true; // TODO: document "authed" prop -} - -export async function deauth(args: NextParamsRRWithSession): Promise { - await setup(args); - args.req.session.__sa.authed = false; -} diff --git a/next.config.js b/next.config.js index c2d7919..87e3fdd 100644 --- a/next.config.js +++ b/next.config.js @@ -2,8 +2,6 @@ var _bundleAnalyzer = _interopRequireDefault(require("@next/bundle-analyzer")); -var _dotenvWebpack = _interopRequireDefault(require("dotenv-webpack")); - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } require('./src/dev-utils').populateEnv(); @@ -25,13 +23,19 @@ module.exports = () => { universe: paths.universe, multiverse: paths.multiverse }); - - if (isServer) { - config.plugins && config.plugins.push(new _dotenvWebpack.default()); - } - return config; + }, + env: { + MAX_LIMIT: process.env.MAX_LIMIT, + LIMIT_OVERRIDE: process.env.LIMIT_OVERRIDE, + DISABLE_RATE_LIMITS: process.env.DISABLE_RATE_LIMITS, + LOCKOUT_ALL_KEYS: process.env.LOCKOUT_ALL_KEYS, + DISALLOW_WRITES: process.env.DISALLOW_WRITES, + REQUESTS_PER_CONTRIVED_ERROR: process.env.REQUESTS_PER_CONTRIVED_ERROR, + MAX_OPTIONS_PER_ELECTION: process.env.MAX_OPTIONS_PER_ELECTION, + MAX_RANKINGS_PER_ELECTION: process.env.MAX_RANKINGS_PER_ELECTION, + MAX_CONTENT_LENGTH_BYTES: process.env.MAX_CONTENT_LENGTH_BYTES } }); }; -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImNvbmZpZy9uZXh0LmNvbmZpZy50cyJdLCJuYW1lcyI6WyJyZXF1aXJlIiwicG9wdWxhdGVFbnYiLCJwYXRocyIsInVuaXZlcnNlIiwiX19kaXJuYW1lIiwibXVsdGl2ZXJzZSIsIm1vZHVsZSIsImV4cG9ydHMiLCJlbmFibGVkIiwicHJvY2VzcyIsImVudiIsIkFOQUxZWkUiLCJkaXN0RGlyIiwid2VicGFjayIsImNvbmZpZyIsImlzU2VydmVyIiwicmVzb2x2ZSIsImFsaWFzIiwicGx1Z2lucyIsInB1c2giLCJEb3RlbnZXZWJwYWNrUGx1Z2luIl0sIm1hcHBpbmdzIjoiOztBQUFBOztBQUNBOzs7O0FBT0FBLE9BQU8sQ0FBQyxpQkFBRCxDQUFQLENBQTJCQyxXQUEzQjs7QUFFQSxNQUFNQyxLQUFLLEdBQUc7QUFDVkMsRUFBQUEsUUFBUSxFQUFHLEdBQUVDLFNBQVUsT0FEYjtBQUVWQyxFQUFBQSxVQUFVLEVBQUcsR0FBRUQsU0FBVTtBQUZmLENBQWQ7O0FBS0FFLE1BQU0sQ0FBQ0MsT0FBUCxHQUFpQixNQUFjO0FBQzNCLFNBQU8sNkJBQW1CO0FBQ3RCQyxJQUFBQSxPQUFPLEVBQUVDLE9BQU8sQ0FBQ0MsR0FBUixDQUFZQyxPQUFaLEtBQXdCO0FBRFgsR0FBbkIsRUFFSjtBQUVDQyxJQUFBQSxPQUFPLEVBQUUsT0FGVjtBQU9DQyxJQUFBQSxPQUFPLEVBQUUsQ0FBQ0MsTUFBRCxFQUF3QjtBQUFFQyxNQUFBQTtBQUFGLEtBQXhCLEtBQWdFO0FBSXJFRCxNQUFBQSxNQUFNLENBQUNFLE9BQVAsS0FBbUJGLE1BQU0sQ0FBQ0UsT0FBUCxDQUFlQyxLQUFmLEdBQXVCLEVBQ3RDLEdBQUdILE1BQU0sQ0FBQ0UsT0FBUCxDQUFlQyxLQURvQjtBQUV0Q2QsUUFBQUEsUUFBUSxFQUFFRCxLQUFLLENBQUNDLFFBRnNCO0FBR3RDRSxRQUFBQSxVQUFVLEVBQUVILEtBQUssQ0FBQ0c7QUFIb0IsT0FBMUM7O0FBTUEsVUFBR1UsUUFBSCxFQUFhO0FBRVRELFFBQUFBLE1BQU0sQ0FBQ0ksT0FBUCxJQUFrQkosTUFBTSxDQUFDSSxPQUFQLENBQWVDLElBQWYsQ0FBb0IsSUFBSUMsc0JBQUosRUFBcEIsQ0FBbEI7QUFDSDs7QUFFRCxhQUFPTixNQUFQO0FBQ0g7QUF2QkYsR0FGSSxDQUFQO0FBMkJILENBNUJEIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHdpdGhCdW5kbGVBbmFseXplciBmcm9tICdAbmV4dC9idW5kbGUtYW5hbHl6ZXInXG5pbXBvcnQgRG90ZW52V2VicGFja1BsdWdpbiBmcm9tICdkb3RlbnYtd2VicGFjaydcblxuaW1wb3J0IHR5cGUgeyBDb25maWd1cmF0aW9uIH0gZnJvbSAnd2VicGFjaydcblxuLy8gPyBOb3QgdXNpbmcgRVM2L1RTIGltcG9ydCBzeW50YXggaGVyZSBiZWNhdXNlIGRldi11dGlscyBoYXMgc3BlY2lhbFxuLy8gPyBjaXJjdW1zdGFuY2VzXG4vLyBlc2xpbnQtZGlzYWJsZS1uZXh0LWxpbmUgaW1wb3J0L25vLXVucmVzb2x2ZWQsIEB0eXBlc2NyaXB0LWVzbGludC9uby12YXItcmVxdWlyZXNcbnJlcXVpcmUoJy4vc3JjL2Rldi11dGlscycpLnBvcHVsYXRlRW52KCk7XG5cbmNvbnN0IHBhdGhzID0ge1xuICAgIHVuaXZlcnNlOiBgJHtfX2Rpcm5hbWV9L3NyYy9gLFxuICAgIG11bHRpdmVyc2U6IGAke19fZGlybmFtZX0vbGliL2AsXG59O1xuXG5tb2R1bGUuZXhwb3J0cyA9ICgpOiBvYmplY3QgPT4ge1xuICAgIHJldHVybiB3aXRoQnVuZGxlQW5hbHl6ZXIoe1xuICAgICAgICBlbmFibGVkOiBwcm9jZXNzLmVudi5BTkFMWVpFID09PSAndHJ1ZSdcbiAgICB9KSh7XG4gICAgICAgIC8vID8gUmVuYW1lcyB0aGUgYnVpbGQgZGlyIFwiYnVpbGRcIiBpbnN0ZWFkIG9mIFwiLm5leHRcIlxuICAgICAgICBkaXN0RGlyOiAnYnVpbGQnLFxuXG4gICAgICAgIC8vID8gV2VicGFjayBjb25maWd1cmF0aW9uXG4gICAgICAgIC8vICEgTm90ZSB0aGF0IHRoZSB3ZWJwYWNrIGNvbmZpZ3VyYXRpb24gaXMgZXhlY3V0ZWQgdHdpY2U6IG9uY2VcbiAgICAgICAgLy8gISBzZXJ2ZXItc2lkZSBhbmQgb25jZSBjbGllbnQtc2lkZSFcbiAgICAgICAgd2VicGFjazogKGNvbmZpZzogQ29uZmlndXJhdGlvbiwgeyBpc1NlcnZlciB9OiB7IGlzU2VydmVyOiBib29sZWFuIH0pID0+IHtcbiAgICAgICAgICAgIC8vID8gVGhlc2UgYXJlIGFsaWFzZXMgdGhhdCBjYW4gYmUgdXNlZCBkdXJpbmcgSlMgaW1wb3J0IGNhbGxzXG4gICAgICAgICAgICAvLyAhIE5vdGUgdGhhdCB5b3UgbXVzdCBhbHNvIGNoYW5nZSB0aGVzZSBzYW1lIGFsaWFzZXMgaW4gdHNjb25maWcuanNvblxuICAgICAgICAgICAgLy8gISBOb3RlIHRoYXQgeW91IG11c3QgYWxzbyBjaGFuZ2UgdGhlc2Ugc2FtZSBhbGlhc2VzIGluIHBhY2thZ2UuanNvbiAoamVzdClcbiAgICAgICAgICAgIGNvbmZpZy5yZXNvbHZlICYmIChjb25maWcucmVzb2x2ZS5hbGlhcyA9IHtcbiAgICAgICAgICAgICAgICAuLi5jb25maWcucmVzb2x2ZS5hbGlhcyxcbiAgICAgICAgICAgICAgICB1bml2ZXJzZTogcGF0aHMudW5pdmVyc2UsXG4gICAgICAgICAgICAgICAgbXVsdGl2ZXJzZTogcGF0aHMubXVsdGl2ZXJzZSxcbiAgICAgICAgICAgIH0pO1xuXG4gICAgICAgICAgICBpZihpc1NlcnZlcikge1xuICAgICAgICAgICAgICAgIC8vID8gQWRkIHJlZmVyZW5jZWQgZW52aXJvbm1lbnQgdmFyaWFibGVzIGRlZmluZWQgaW4gLmVudiB0byBidW5kbGVcbiAgICAgICAgICAgICAgICBjb25maWcucGx1Z2lucyAmJiBjb25maWcucGx1Z2lucy5wdXNoKG5ldyBEb3RlbnZXZWJwYWNrUGx1Z2luKCkpO1xuICAgICAgICAgICAgfVxuXG4gICAgICAgICAgICByZXR1cm4gY29uZmlnO1xuICAgICAgICB9XG4gICAgfSk7XG59O1xuIl19 \ No newline at end of file +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImNvbmZpZy9uZXh0LmNvbmZpZy50cyJdLCJuYW1lcyI6WyJyZXF1aXJlIiwicG9wdWxhdGVFbnYiLCJwYXRocyIsInVuaXZlcnNlIiwiX19kaXJuYW1lIiwibXVsdGl2ZXJzZSIsIm1vZHVsZSIsImV4cG9ydHMiLCJlbmFibGVkIiwicHJvY2VzcyIsImVudiIsIkFOQUxZWkUiLCJkaXN0RGlyIiwid2VicGFjayIsImNvbmZpZyIsImlzU2VydmVyIiwicmVzb2x2ZSIsImFsaWFzIiwiTUFYX0xJTUlUIiwiTElNSVRfT1ZFUlJJREUiLCJESVNBQkxFX1JBVEVfTElNSVRTIiwiTE9DS09VVF9BTExfS0VZUyIsIkRJU0FMTE9XX1dSSVRFUyIsIlJFUVVFU1RTX1BFUl9DT05UUklWRURfRVJST1IiLCJNQVhfT1BUSU9OU19QRVJfRUxFQ1RJT04iLCJNQVhfUkFOS0lOR1NfUEVSX0VMRUNUSU9OIiwiTUFYX0NPTlRFTlRfTEVOR1RIX0JZVEVTIl0sIm1hcHBpbmdzIjoiOztBQUFBOzs7O0FBUUFBLE9BQU8sQ0FBQyxpQkFBRCxDQUFQLENBQTJCQyxXQUEzQjs7QUFFQSxNQUFNQyxLQUFLLEdBQUc7QUFDVkMsRUFBQUEsUUFBUSxFQUFHLEdBQUVDLFNBQVUsT0FEYjtBQUVWQyxFQUFBQSxVQUFVLEVBQUcsR0FBRUQsU0FBVTtBQUZmLENBQWQ7O0FBS0FFLE1BQU0sQ0FBQ0MsT0FBUCxHQUFpQixNQUFjO0FBQzNCLFNBQU8sNkJBQW1CO0FBQ3RCQyxJQUFBQSxPQUFPLEVBQUVDLE9BQU8sQ0FBQ0MsR0FBUixDQUFZQyxPQUFaLEtBQXdCO0FBRFgsR0FBbkIsRUFFSjtBQUVDQyxJQUFBQSxPQUFPLEVBQUUsT0FGVjtBQU9DQyxJQUFBQSxPQUFPLEVBQUUsQ0FBQ0MsTUFBRCxFQUF3QjtBQUFFQyxNQUFBQTtBQUFGLEtBQXhCLEtBQWdFO0FBSXJFRCxNQUFBQSxNQUFNLENBQUNFLE9BQVAsS0FBbUJGLE1BQU0sQ0FBQ0UsT0FBUCxDQUFlQyxLQUFmLEdBQXVCLEVBQ3RDLEdBQUdILE1BQU0sQ0FBQ0UsT0FBUCxDQUFlQyxLQURvQjtBQUV0Q2QsUUFBQUEsUUFBUSxFQUFFRCxLQUFLLENBQUNDLFFBRnNCO0FBR3RDRSxRQUFBQSxVQUFVLEVBQUVILEtBQUssQ0FBQ0c7QUFIb0IsT0FBMUM7QUFNQSxhQUFPUyxNQUFQO0FBQ0gsS0FsQkY7QUF1QkNKLElBQUFBLEdBQUcsRUFBRTtBQUNEUSxNQUFBQSxTQUFTLEVBQUVULE9BQU8sQ0FBQ0MsR0FBUixDQUFZUSxTQUR0QjtBQUVEQyxNQUFBQSxjQUFjLEVBQUVWLE9BQU8sQ0FBQ0MsR0FBUixDQUFZUyxjQUYzQjtBQUdEQyxNQUFBQSxtQkFBbUIsRUFBRVgsT0FBTyxDQUFDQyxHQUFSLENBQVlVLG1CQUhoQztBQUlEQyxNQUFBQSxnQkFBZ0IsRUFBRVosT0FBTyxDQUFDQyxHQUFSLENBQVlXLGdCQUo3QjtBQUtEQyxNQUFBQSxlQUFlLEVBQUViLE9BQU8sQ0FBQ0MsR0FBUixDQUFZWSxlQUw1QjtBQU1EQyxNQUFBQSw0QkFBNEIsRUFBRWQsT0FBTyxDQUFDQyxHQUFSLENBQVlhLDRCQU56QztBQU9EQyxNQUFBQSx3QkFBd0IsRUFBRWYsT0FBTyxDQUFDQyxHQUFSLENBQVljLHdCQVByQztBQVFEQyxNQUFBQSx5QkFBeUIsRUFBRWhCLE9BQU8sQ0FBQ0MsR0FBUixDQUFZZSx5QkFSdEM7QUFTREMsTUFBQUEsd0JBQXdCLEVBQUVqQixPQUFPLENBQUNDLEdBQVIsQ0FBWWdCO0FBVHJDO0FBdkJOLEdBRkksQ0FBUDtBQXFDSCxDQXRDRCIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB3aXRoQnVuZGxlQW5hbHl6ZXIgZnJvbSAnQG5leHQvYnVuZGxlLWFuYWx5emVyJ1xuaW1wb3J0IERvdGVudldlYnBhY2tQbHVnaW4gZnJvbSAnZG90ZW52LXdlYnBhY2snXG5cbmltcG9ydCB0eXBlIHsgQ29uZmlndXJhdGlvbiB9IGZyb20gJ3dlYnBhY2snXG5cbi8vID8gTm90IHVzaW5nIEVTNi9UUyBpbXBvcnQgc3ludGF4IGhlcmUgYmVjYXVzZSBkZXYtdXRpbHMgaGFzIHNwZWNpYWxcbi8vID8gY2lyY3Vtc3RhbmNlc1xuLy8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIGltcG9ydC9uby11bnJlc29sdmVkLCBAdHlwZXNjcmlwdC1lc2xpbnQvbm8tdmFyLXJlcXVpcmVzXG5yZXF1aXJlKCcuL3NyYy9kZXYtdXRpbHMnKS5wb3B1bGF0ZUVudigpO1xuXG5jb25zdCBwYXRocyA9IHtcbiAgICB1bml2ZXJzZTogYCR7X19kaXJuYW1lfS9zcmMvYCxcbiAgICBtdWx0aXZlcnNlOiBgJHtfX2Rpcm5hbWV9L2xpYi9gLFxufTtcblxubW9kdWxlLmV4cG9ydHMgPSAoKTogb2JqZWN0ID0+IHtcbiAgICByZXR1cm4gd2l0aEJ1bmRsZUFuYWx5emVyKHtcbiAgICAgICAgZW5hYmxlZDogcHJvY2Vzcy5lbnYuQU5BTFlaRSA9PT0gJ3RydWUnXG4gICAgfSkoe1xuICAgICAgICAvLyA/IFJlbmFtZXMgdGhlIGJ1aWxkIGRpciBcImJ1aWxkXCIgaW5zdGVhZCBvZiBcIi5uZXh0XCJcbiAgICAgICAgZGlzdERpcjogJ2J1aWxkJyxcblxuICAgICAgICAvLyA/IFdlYnBhY2sgY29uZmlndXJhdGlvblxuICAgICAgICAvLyAhIE5vdGUgdGhhdCB0aGUgd2VicGFjayBjb25maWd1cmF0aW9uIGlzIGV4ZWN1dGVkIHR3aWNlOiBvbmNlXG4gICAgICAgIC8vICEgc2VydmVyLXNpZGUgYW5kIG9uY2UgY2xpZW50LXNpZGUhXG4gICAgICAgIHdlYnBhY2s6IChjb25maWc6IENvbmZpZ3VyYXRpb24sIHsgaXNTZXJ2ZXIgfTogeyBpc1NlcnZlcjogYm9vbGVhbiB9KSA9PiB7XG4gICAgICAgICAgICAvLyA/IFRoZXNlIGFyZSBhbGlhc2VzIHRoYXQgY2FuIGJlIHVzZWQgZHVyaW5nIEpTIGltcG9ydCBjYWxsc1xuICAgICAgICAgICAgLy8gISBOb3RlIHRoYXQgeW91IG11c3QgYWxzbyBjaGFuZ2UgdGhlc2Ugc2FtZSBhbGlhc2VzIGluIHRzY29uZmlnLmpzb25cbiAgICAgICAgICAgIC8vICEgTm90ZSB0aGF0IHlvdSBtdXN0IGFsc28gY2hhbmdlIHRoZXNlIHNhbWUgYWxpYXNlcyBpbiBwYWNrYWdlLmpzb24gKGplc3QpXG4gICAgICAgICAgICBjb25maWcucmVzb2x2ZSAmJiAoY29uZmlnLnJlc29sdmUuYWxpYXMgPSB7XG4gICAgICAgICAgICAgICAgLi4uY29uZmlnLnJlc29sdmUuYWxpYXMsXG4gICAgICAgICAgICAgICAgdW5pdmVyc2U6IHBhdGhzLnVuaXZlcnNlLFxuICAgICAgICAgICAgICAgIG11bHRpdmVyc2U6IHBhdGhzLm11bHRpdmVyc2UsXG4gICAgICAgICAgICB9KTtcblxuICAgICAgICAgICAgcmV0dXJuIGNvbmZpZztcbiAgICAgICAgfSxcblxuICAgICAgICAvLyA/IFNlbGVjdCBzb21lIGVudmlyb25tZW50IHZhcmlhYmxlcyBkZWZpbmVkIGluIC5lbnYgdG8gcHVzaCB0byB0aGVcbiAgICAgICAgLy8gPyBjbGllbnQuXG4gICAgICAgIC8vICEhIERPIE5PVCBQVVQgQU5ZIFNFQ1JFVCBFTlZJUk9OTUVOVCBWQVJJQUJMRVMgSEVSRSAhIVxuICAgICAgICBlbnY6IHtcbiAgICAgICAgICAgIE1BWF9MSU1JVDogcHJvY2Vzcy5lbnYuTUFYX0xJTUlULFxuICAgICAgICAgICAgTElNSVRfT1ZFUlJJREU6IHByb2Nlc3MuZW52LkxJTUlUX09WRVJSSURFLFxuICAgICAgICAgICAgRElTQUJMRV9SQVRFX0xJTUlUUzogcHJvY2Vzcy5lbnYuRElTQUJMRV9SQVRFX0xJTUlUUyxcbiAgICAgICAgICAgIExPQ0tPVVRfQUxMX0tFWVM6IHByb2Nlc3MuZW52LkxPQ0tPVVRfQUxMX0tFWVMsXG4gICAgICAgICAgICBESVNBTExPV19XUklURVM6IHByb2Nlc3MuZW52LkRJU0FMTE9XX1dSSVRFUyxcbiAgICAgICAgICAgIFJFUVVFU1RTX1BFUl9DT05UUklWRURfRVJST1I6IHByb2Nlc3MuZW52LlJFUVVFU1RTX1BFUl9DT05UUklWRURfRVJST1IsXG4gICAgICAgICAgICBNQVhfT1BUSU9OU19QRVJfRUxFQ1RJT046IHByb2Nlc3MuZW52Lk1BWF9PUFRJT05TX1BFUl9FTEVDVElPTixcbiAgICAgICAgICAgIE1BWF9SQU5LSU5HU19QRVJfRUxFQ1RJT046IHByb2Nlc3MuZW52Lk1BWF9SQU5LSU5HU19QRVJfRUxFQ1RJT04sXG4gICAgICAgICAgICBNQVhfQ09OVEVOVF9MRU5HVEhfQllURVM6IHByb2Nlc3MuZW52Lk1BWF9DT05URU5UX0xFTkdUSF9CWVRFUyxcbiAgICAgICAgfVxuICAgIH0pO1xufTtcbiJdfQ== \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 037e97b..3c0179a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2126,6 +2126,12 @@ "@types/node": "*" } }, + "@types/bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-5YG1AiIC8HPPXRvYAIa7ehK3YMAwd0DWiPCtpuL9sgKceWLyWsVtLRA+lT4NkoanDNF9slwQ66lPizWDpgRlWA==", + "dev": true + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -3940,8 +3946,7 @@ "bytes": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", - "dev": true + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" }, "cacache": { "version": "13.0.1", @@ -4450,7 +4455,8 @@ "cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", - "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "dev": true }, "cookie-signature": { "version": "1.0.6", @@ -6382,7 +6388,8 @@ "find-package-json": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/find-package-json/-/find-package-json-1.2.0.tgz", - "integrity": "sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==" + "integrity": "sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==", + "dev": true }, "find-up": { "version": "2.1.0", @@ -7932,11 +7939,6 @@ "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz", "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=" }, - "immer": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/immer/-/immer-6.0.6.tgz", - "integrity": "sha512-KAo8XDbDcF59lDlKEFOhyssB/z6805ZvH/S3wqMPaTzLMFDUUu1Lq647LrUyuXzI36wMpzwZ83mMxwOXM961aA==" - }, "import-fresh": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", @@ -10635,11 +10637,6 @@ "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", "optional": true }, - "nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" - }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -10856,15 +10853,6 @@ } } }, - "next-session": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/next-session/-/next-session-3.1.0.tgz", - "integrity": "sha512-DbCA0sJ4U3l2pEyfIKVf1v5GgsghbUcLR0ssdzCG+x4c4CbxVUfIH7k7AjrgclEBPkhq1H21yjYXMB8Rn5EGdA==", - "requires": { - "cookie": "^0.4.0", - "nanoid": "^2.1.11" - } - }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", diff --git a/package.json b/package.json index 5144075..2916c08 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,16 @@ }, "expectedEnvVariables": [ "NODE_ENV|BABEL_ENV|APP_ENV", - "MONGODB_URI" + "MONGODB_URI", + "MAX_LIMIT", + "LIMIT_OVERRIDE", + "DISABLE_RATE_LIMITS", + "LOCKOUT_ALL_KEYS", + "DISALLOW_WRITES", + "REQUESTS_PER_CONTRIVED_ERROR", + "MAX_OPTIONS_PER_ELECTION", + "MAX_RANKINGS_PER_ELECTION", + "MAX_CONTENT_LENGTH_BYTES" ], "dependencies": { "@babel/cli": "^7.8.4", @@ -57,17 +66,15 @@ "@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/plugin-proposal-throw-expressions": "^7.8.3", "@babel/preset-env": "^7.9.6", + "bytes": "^3.1.0", "confusing-browser-globals": "^1.0.9", "dotenv": "^8.2.0", "dotenv-webpack": "^1.8.0", "eslint-import-resolver-alias": "^1.1.2", "fast-shuffle": "^3.0.0", - "find-package-json": "^1.2.0", - "immer": "^6.0.6", "isomorphic-unfetch": "^3.0.0", "mongodb": "^3.5.7", "next": "^9.4.2", - "next-session": "^3.1.0", "next-transpile-modules": "^3.3.0", "react": "^16.13.1", "react-dom": "^16.13.1" @@ -76,6 +83,7 @@ "@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/preset-typescript": "^7.9.0", "@next/bundle-analyzer": "^9.4.2", + "@types/bytes": "^3.1.0", "@types/dotenv-webpack": "^1.7.0", "@types/fancy-log": "^1.3.1", "@types/fast-shuffle": "^1.0.1", @@ -103,7 +111,6 @@ "gulp-tap": "^2.0.0", "jest": "^26.0.1", "jest-extended": "^0.11.5", - "mongodb-memory-server": "^6.6.1", "random-int": "^2.0.1", "source-map-support": "^0.5.19", "typescript": "^3.9.3", diff --git a/src/backend/__test__/backend.test.ts b/src/backend/__test__/backend.test.ts new file mode 100644 index 0000000..9f4ed9b --- /dev/null +++ b/src/backend/__test__/backend.test.ts @@ -0,0 +1,896 @@ +import { ObjectId } from 'mongodb'; +import * as Backend from 'universe/backend' +import { setupJest, unhydratedDummyDbData } from './db' +import { getEnv } from '../env' +import { populateEnv } from 'universe/dev-utils' + +import type{ NextApiRequest, NextApiResponse } from 'next'; +import { InternalElection, NewElection, PatchElection, Rankings, PublicElection } from 'types/global' +import randomInt from 'random-int'; + +populateEnv(); +jest.setTimeout(10000); + +const { getHydratedData, getDb } = setupJest(); + +const getExpectedMeta = () => { + const expectedMeta = { + upcomingElections: 0, + openElections: 0, + closedElections: 0 + }; + + unhydratedDummyDbData.elections.forEach(election => { + election.closes <= Date.now() + ? expectedMeta.closedElections++ + : (election.opens > Date.now() ? expectedMeta.upcomingElections++ : expectedMeta.openElections++); + }); + + return expectedMeta; +} + +describe('universe/backend', () => { + describe('::getElectionMetadata', () => { + it('returns expected metadata', + async () => expect(await Backend.getElectionMetadata()).toEqual(getExpectedMeta())); + }); + + describe('::getPublicElections', () => { + it('returns only public elections data', async () => { + const elections = getHydratedData().elections; + const index = randomInt(0, elections.length - 1); + + const { + title, + _id, + closes, + created, + deleted, + description, + opens, + options, + owner, + ...rest + } = elections[index]; + + expect(Object.keys(rest).length).toBe(0); + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: 1, + after: elections[index - 1]?._id + })).toArray()).toEqual([{ + election_id: _id, + title, + description, + options, + created, + opens, + closes, + deleted, + owned: owner == Backend.NULL_KEY + }]); + }); + + it('rejects if no key is provided', async () => { + /* eslint-disable @typescript-eslint/ban-ts-ignore */ + // @ts-ignore + expect(Backend.getPublicElections()).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: 5 })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: null })).toReject(); + /* eslint-enable @typescript-eslint/ban-ts-ignore */ + }); + + it('returns paginated data respecting limit and after', async () => { + const elections = getHydratedData().elections.map(e => { + const { _id, owner,...election } = e; + + return { + ...election, + election_id: _id, + owned: owner == Backend.NULL_KEY, + } as PublicElection + }); + + const defaultResults = elections.slice(0, Backend.DEFAULT_RESULT_LIMIT); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: Backend.DEFAULT_RESULT_LIMIT + })).toArray()).toEqual(defaultResults); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + after: null + })).toArray()).toEqual(defaultResults); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: Backend.DEFAULT_RESULT_LIMIT, + after: null + })).toArray()).toEqual(defaultResults); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + after: elections[0].election_id + })).toArray()).toEqual(elections.slice(1, Backend.DEFAULT_RESULT_LIMIT + 1)); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: 1 + })).toArray()).toEqual(elections.slice(0, 1)); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: 1, + after: elections[0].election_id + })).toArray()).toEqual(elections.slice(1, 2)); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: 3, + after: elections[1].election_id + })).toArray()).toEqual(elections.slice(2, 5)); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + limit: getEnv().MAX_LIMIT, + after: elections[11].election_id + })).toArray()).toEqual(elections.slice(12, getEnv().MAX_LIMIT + 12)); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + after: elections[elections.length - 1].election_id + })).toArray()).toEqual([]); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + after: elections[elections.length - 2].election_id + })).toArray()).toEqual(elections.slice(-1)); + + expect(await (await Backend.getPublicElections({ + key: Backend.NULL_KEY, + after: new ObjectId() + })).toArray()).toEqual([]); + }); + + it('rejects on non-positive/too large limit or non-existent after', async () => { + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: getEnv().MAX_LIMIT + 1 })).toReject(); + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: 0 })).toReject(); + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: -1 })).toReject(); + + expect(() => Backend.getPublicElections({ + key: Backend.NULL_KEY, + after: new ObjectId('doesnE!') + })).toThrow(); + + expect(Backend.getPublicElections({ + key: Backend.NULL_KEY, + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + after: 'notAnObjectId!' + })).toReject(); + }); + + it('rejects on strange/bad limit and/or after', async () => { + /* eslint-disable @typescript-eslint/ban-ts-ignore */ + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: 'lol' })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: null })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: false })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, limit: new ObjectId() })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, after: 0 })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, after: 100 })).toReject(); + // @ts-ignore + expect(Backend.getPublicElections({ key: Backend.NULL_KEY, after: false })).toReject(); + /* eslint-enable @typescript-eslint/ban-ts-ignore */ + }); + }); + + describe('::getPublicElection', () => { + it('returns only public election data', async () => { + const election = getHydratedData().elections[0]; + + expect(await Backend.getPublicElection({ + electionId: election._id, + key: Backend.NULL_KEY + })).toEqual({ + election_id: election._id, + title: election.title, + description: election.description, + options: election.options as string[], + created: election.created, + opens: election.opens, + closes: election.closes, + deleted: election.deleted, + owned: election.owner == Backend.NULL_KEY + }); + }); + + it('rejects if election_id does not exist', async () => { + expect(Backend.getPublicElection({ electionId: new ObjectId(), key: Backend.NULL_KEY })).toReject(); + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + //@ts-ignore + expect(Backend.getPublicElection({ electionId: 'not a real id', key: Backend.NULL_KEY })).toReject(); + }); + }); + + describe('::getInternalElection', () => { + it('returns internal election data', async () => { + const elections = getHydratedData().elections; + + expect(await Backend.getInternalElection(elections[0]._id)).toEqual(elections[0]); + expect(await Backend.getInternalElection(elections[1]._id)).toEqual(elections[1]); + expect(await Backend.getInternalElection(elections[5]._id)).toEqual(elections[5]); + expect(await Backend.getInternalElection(elections[elections.length - 1]._id)) + .toEqual(elections[elections.length - 1]); + }); + + it('rejects if election_id does not exist', async () => { + expect(Backend.getInternalElection(new ObjectId())).toReject(); + }); + }); + + describe('::doesElectionExist', () => { + it('returns expected result with various elections, some non-existent', async () => { + const elections = getHydratedData().elections; + + expect(await Backend.doesElectionExist(new ObjectId())).toEqual(false); + /* eslint-disable @typescript-eslint/ban-ts-ignore */ + // @ts-ignore + expect(Backend.doesElectionExist(null)).toReject(); + // @ts-ignore + expect(Backend.doesElectionExist(undefined)).toReject(); + /* eslint-enable @typescript-eslint/ban-ts-ignore */ + expect(await Backend.doesElectionExist(elections[0]._id)).toEqual(true); + expect(await Backend.doesElectionExist(elections[1]._id)).toEqual(true); + expect(await Backend.doesElectionExist(elections[5]._id)).toEqual(true); + expect(await Backend.doesElectionExist(elections[10]._id)).toEqual(true); + expect(await Backend.doesElectionExist(elections[elections.length - 1]._id)).toEqual(true); + }); + }); + + describe('::upsertElection', () => { + it('inserts a new election when election_id does not exist', async () => { + const newElection = { + title: 'New election', + description: 'This is a new election', + options: ['1', '2'], + opens: Date.now() + 1000, + closes: Date.now() + 10000 + }; + + // ? Bad props should be ignored + const badProps = { + /* eslint-disable @typescript-eslint/ban-ts-ignore */ + // @ts-ignore + _id: new ObjectId(), + // @ts-ignore + created: 0, + //@ ts-ignore + deleted: false, + // @ts-ignore + owner: 'fake-owner-id', + // @ts-ignore + fakeprop: 'bad', + /* eslint-enable @typescript-eslint/ban-ts-ignore */ + }; + + const election = await Backend.upsertElection({ + election: { ...newElection, ...badProps } as NewElection, + key: Backend.NULL_KEY + }); + + const returnedElection = await Backend.getInternalElection(election._id || new ObjectId('bad')); + expect(returnedElection._id).not.toEqual(badProps._id); + expect(returnedElection.owner).toEqual(Backend.NULL_KEY); + expect(returnedElection).toEqual(election); + }); + + it('updates an existing election when election_id already exists', async () => { + const newElection: NewElection = { + title: 'New election', + description: 'This is a new election', + options: ['1', '2'], + opens: Date.now() + 1000, + closes: Date.now() + 10000, + }; + + const election1 = await Backend.upsertElection({ election: newElection, key: Backend.NULL_KEY }); + const election2 = await Backend.upsertElection({ + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + election: { ...newElection, created: 100, opens: 200, closes: 300 }, + key: Backend.NULL_KEY + }); + + expect(election1._id).toEqual(election2._id); + expect(election1.opens).toEqual(newElection.opens); + expect(election2.opens).toEqual(200); + + const returnedElection = await Backend.getInternalElection(election1._id || new ObjectId('bad')); + + expect(returnedElection._id).toEqual(election1._id); + expect(returnedElection.created).toEqual(election1.created); + // ? Bad props should be ignored! + expect(returnedElection.created).not.toEqual(100); + expect(returnedElection.opens).toEqual(200); + expect(returnedElection.closes).toEqual(300); + }); + + it('rejects when missing necessary params', async () => { + /* eslint-disable @typescript-eslint/ban-ts-ignore */ + // @ts-ignore + const newElection1: NewElection = { + description: 'This is a new election', + options: ['1', '2'], + opens: Date.now() + 1000, + closes: Date.now() + 10000 + }; + // @ts-ignore + const newElection2: NewElection = { + title: 'New election', + description: 'This is a new election', + closes: Date.now() + 10000 + }; + // @ts-ignore + const newElection3: NewElection = { + title: 'New election', + opens: Date.now() + 1000, + }; + /* eslint-enable @typescript-eslint/ban-ts-ignore */ + + expect(Backend.upsertElection({ election: newElection1, key: Backend.NULL_KEY })).toReject(); + expect(Backend.upsertElection({ election: newElection2, key: Backend.NULL_KEY })).toReject(); + expect(Backend.upsertElection({ election: newElection3, key: Backend.NULL_KEY })).toReject(); + }); + }); + + describe('::isKeyAuthentic', () => { + it('returns expected result on valid and invalid keys', async () => { + expect(await Backend.isKeyAuthentic('')).toEqual(false); + expect(await Backend.isKeyAuthentic('d68d8b5e-b926-4925-ac77-1013e56b8c81')).toEqual(false); + expect(await Backend.isKeyAuthentic(getHydratedData().keys[0].key)).toEqual(true); + }); + + it('returns false if key is NULL_KEY', async () => { + expect(await Backend.isKeyAuthentic(Backend.NULL_KEY)).toEqual(false); + }); + }); + + describe('::deleteElection', () => { + it('soft deletes but does not eliminate election document', async () => { + const election_id = getHydratedData().elections[0]._id; + + expect(await Backend.doesElectionExist(election_id)).toEqual(true); + + await Backend.deleteElection(election_id); + + expect(await Backend.doesElectionExist(election_id)).toEqual(true); + expect((await Backend.getInternalElection(election_id)).deleted).toEqual(true); + }); + }); + + describe('::replaceRankings and ::getRankings', () => { + it("::replaceRankings replaces an election's rankings data, returned by ::getRankings", async () => { + const election = getHydratedData().elections[0]; + const oldRankings = await Backend.getRankings(election._id); + const newRankings = [{ voter_id: '1', rankings: election.options }]; + + await Backend.replaceRankings(election._id, newRankings); + + expect(oldRankings.length > 0).toEqual(true); + expect(await Backend.getRankings(election._id)).toEqual(newRankings); + + await Backend.replaceRankings(election._id, oldRankings); + + expect(await Backend.getRankings(election._id)).toEqual(oldRankings); + }); + + it('::replaceRankings and ::getRankings throw if election_id does not exist', async () => { + expect(Backend.replaceRankings(new ObjectId(), [])).toReject(); + expect(Backend.getRankings(new ObjectId())).toReject(); + }); + }); + + describe('::validateElectionData', () => { + it('returns true on valid new elections', async () => { + const newElection1: NewElection = { + title: 'New election', + description: 'This is a new election', + options: ['1', '2'], + opens: Date.now() + 1000, + closes: Date.now() + 10000, + }; + + const newElection2: NewElection = { + title: 'New election', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + }; + + expect(await Backend.validateElectionData(newElection1)).toEqual(true); + expect(await Backend.validateElectionData(newElection2)).toEqual(true); + }); + + it('returns true on valid election updates', async () => { + const electionPatch1: PatchElection = { _id: new ObjectId() }; + const electionPatch2: PatchElection = { + _id: new ObjectId(), + title: 'New election', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + }; + + const electionPatch3: PatchElection = { + _id: new ObjectId(), + options: [], + }; + + const electionPatch4: PatchElection = { + _id: new ObjectId(), + options: undefined, + }; + + expect(await Backend.validateElectionData(electionPatch1, { patch: true })).toEqual(true); + expect(await Backend.validateElectionData(electionPatch2, { patch: true })).toEqual(true); + expect(await Backend.validateElectionData(electionPatch3, { patch: true })).toEqual(true); + expect(await Backend.validateElectionData(electionPatch4, { patch: true })).toEqual(true); + }); + + it('rejects for new elections when illegal keys provided or required keys missing', async () => { + const newElection0 = {} as unknown as NewElection; + + const newElection1 = { + opens: Date.now() + 1000, + closes: Date.now() + 10000, + } as unknown as NewElection; + + const newElection2 = { + title: 'My new election!', + description: 'This is a new election', + options: ['1', '2'], + closes: Date.now() + 10000, + } as unknown as NewElection; + + const newElection3 = { + title: 'My new election!', + opens: Date.now() + 1000, + } as unknown as NewElection; + + const newElection4 = { + title: 'My new election!', + created: 0, + opens: Date.now() + 1000, + closes: Date.now() + 10000 + } as unknown as NewElection; + + const newElection4b = { + title: 'My new election!', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + deleted: false + } as unknown as NewElection; + + const newElection5 = { + title: 'My new election!', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + owned: true + } as unknown as NewElection; + + const newElection6 = { + title: 'My new election!', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + owner: Backend.NULL_KEY + } as unknown as NewElection; + + const newElection7 = { + election_id: new ObjectId(), + title: 'My new election!', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + } as unknown as NewElection; + + const newElection8 = { + _id: new ObjectId(), + title: 'My new election!', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + } as unknown as NewElection; + + const newElection9 = { + title: 'My new election!', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + blahblahlblah: true, + } as unknown as NewElection; + + expect(Backend.validateElectionData(newElection0)).toReject(); + expect(Backend.validateElectionData(newElection1)).toReject(); + expect(Backend.validateElectionData(newElection2)).toReject(); + expect(Backend.validateElectionData(newElection3)).toReject(); + expect(Backend.validateElectionData(newElection4)).toReject(); + expect(Backend.validateElectionData(newElection4b)).toReject(); + expect(Backend.validateElectionData(newElection5)).toReject(); + expect(Backend.validateElectionData(newElection6)).toReject(); + expect(Backend.validateElectionData(newElection7)).toReject(); + expect(Backend.validateElectionData(newElection8)).toReject(); + expect(Backend.validateElectionData(newElection9)).toReject(); + }); + + it('rejects for updating elections when illegal keys provided', async () => { + const patchElection1 = {} as unknown as PatchElection; + + const patchElection2 = { + created: 0, + } as unknown as PatchElection; + + const patchElection3 = { + owner: Backend.NULL_KEY, + } as unknown as PatchElection; + + const patchElection4 = { + options: null, + } as unknown as PatchElection; + + const patchElection5 = { + owned: true + } as unknown as PatchElection; + + const patchElection6 = { + election_id: new ObjectId(), + } as unknown as PatchElection; + + expect(Backend.validateElectionData(patchElection1, { patch: true })).toReject(); + expect(Backend.validateElectionData(patchElection2, { patch: true })).toReject(); + expect(Backend.validateElectionData(patchElection3, { patch: true })).toReject(); + expect(Backend.validateElectionData(patchElection4, { patch: true })).toReject(); + expect(Backend.validateElectionData(patchElection5, { patch: true })).toReject(); + expect(Backend.validateElectionData(patchElection6, { patch: true })).toReject(); + }); + + it('rejects on elections with options.length > MAX_OPTIONS_PER_ELECTION', async () => { + const newElection: NewElection = { + title: 'New election', + description: 'This is a new election', + options: [...Array(getEnv().MAX_OPTIONS_PER_ELECTION + 1)].map((_, ndx) => ndx.toString()), + opens: Date.now() + 1000, + closes: Date.now() + 10000, + }; + + expect(Backend.validateElectionData(newElection)).toReject(); + expect(Backend.validateElectionData(newElection, { patch: true })).toReject(); + }); + + it('rejects when !(created <= opens <= closes)', async () => { + const newElection1: NewElection = { + title: 'New election', + description: 'This is a new election', + opens: 1, + closes: 2, + }; + + const newElection2: NewElection = { + title: 'New election', + description: 'This is a new election', + opens: Date.now(), + closes: 2, + }; + + const newElection3: NewElection = { + title: 'New election', + description: 'This is a new election', + opens: 1, + closes: Date.now(), + }; + + expect(Backend.validateElectionData(newElection1)).toReject(); + expect(Backend.validateElectionData(newElection2)).toReject(); + expect(Backend.validateElectionData(newElection3)).toReject(); + }); + + it('rejects if any of the values are the incorrect type (number/string)', async () => { + const newElection1 = { + title: null, + opens: Date.now() + 1000, + closes: Date.now() + 10000, + } as unknown as InternalElection; + + const newElection2 = { + title: '', + opens: null, + closes: Date.now() + 10000, + } as unknown as InternalElection; + + const newElection3 = { + title: '', + opens: Date.now() + 1000, + closes: '10000', + } as unknown as InternalElection; + + const newElection4 = { + title: '', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + options: null + } as unknown as InternalElection; + + const newElection5 = { + title: '', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + options: [1, 2] + } as unknown as InternalElection; + + const newElection6 = { + title: '', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + options: [true] + } as unknown as InternalElection; + + const newElection7 = { + title: '', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + options: [undefined] + } as unknown as InternalElection; + + const newElection8 = { + title: '', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + description: null + } as unknown as InternalElection; + + const newElection9 = { + title: '', + opens: Date.now() + 1000, + closes: Date.now() + 10000, + description: undefined + } as unknown as InternalElection; + + const newElection10 = { + title: undefined, + opens: Date.now() + 1000, + closes: Date.now() + 10000, + } as unknown as InternalElection; + + expect(Backend.validateElectionData(newElection1)).toReject(); + expect(Backend.validateElectionData(newElection2)).toReject(); + expect(Backend.validateElectionData(newElection3)).toReject(); + expect(Backend.validateElectionData(newElection4)).toReject(); + expect(Backend.validateElectionData(newElection5)).toReject(); + expect(Backend.validateElectionData(newElection6)).toReject(); + expect(Backend.validateElectionData(newElection7)).toReject(); + expect(Backend.validateElectionData(newElection8)).toReject(); + expect(Backend.validateElectionData(newElection9)).toReject(); + expect(Backend.validateElectionData(newElection10)).toReject(); + + expect(Backend.validateElectionData(newElection1, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection2, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection3, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection4, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection5, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection6, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection7, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection8, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection9, { patch: true })).toReject(); + expect(Backend.validateElectionData(newElection10, { patch: true })).toReject(); + }); + }); + + describe('::validateRankingsData', () => { + it('returns true on valid rankings and false on invalid rankings', async () => { + const election = getHydratedData().elections[0]; + const newRankings1: Rankings = []; + + const newRankings2: Rankings = [ + { voter_id: 'my-userid1', ranking: election.options }, + ]; + + const newRankings3: Rankings = [ + { voter_id: 'my-userid1', ranking: election.options }, + { voter_id: 'my-userid2', ranking: election.options }, + { voter_id: 'my-userid3', ranking: election.options }, + ]; + + expect(Backend.validateRankingsData(election._id, null as unknown as Rankings)).toReject(); + expect(Backend.validateRankingsData(election._id, false as unknown as Rankings)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings1)).toBe(true); + expect(Backend.validateRankingsData(election._id, newRankings2)).toBe(true); + expect(Backend.validateRankingsData(election._id, newRankings3)).toBe(true); + }); + + it('rejects on rankings with length > MAX_RANKINGS_PER_ELECTION', async () => { + const election = getHydratedData().elections[0]; + const newRankings = [...Array(getEnv().MAX_RANKINGS_PER_ELECTION + 1)].map((_, ndx) => + ({ voter_id: ndx.toString(), ranking: election.options })); + + expect(Backend.validateRankingsData(election._id, newRankings)).toReject(); + }); + + it('rejects on rankings that include non-existent options for the election', async () => { + expect(Backend.validateRankingsData( + getHydratedData().elections[0]._id, [{ voter_id: '5', ranking: ['FAKE'] }] + )).toReject(); + }); + + it('rejects if any of the ids or rankings are not the correct type', async () => { + const election = getHydratedData().elections[0]; + const newRankings1 = [undefined]; + const newRankings2 = [null]; + const newRankings3 = [[]]; + const newRankings4 = [{}]; + const newRankings5 = [[{}]]; + const newRankings6 = [{ blah: 'blah' }]; + const newRankings7 = [{ voter_id: 'blah' }]; + const newRankings8 = [{ ranking: election.options }]; + const newRankings9 = [{ voter_id: 5, ranking: election.options }]; + const newRankings10 = [{ voter_id: true, ranking: election.options }]; + const newRankings11 = [{ voter_id: 'blah', ranking: true }]; + const newRankings12 = [{ voter_id: undefined, ranking: undefined }]; + const newRankings13 = [{ voter_id: null, ranking: null }]; + const newRankings14 = [{ voter_id: null, ranking: election.options }]; + const newRankings15 = [{ voter_id: undefined, ranking: election.options }]; + const newRankings16 = [{ voter_id: 'blah', ranking: null }]; + const newRankings17 = [{ voter_id: 'blah', ranking: undefined }]; + const newRankings18 = [{ voter_id: 'blah', ranking: [...election.options, undefined] }]; + const newRankings19 = [{ voter_id: 'blah', ranking: [...election.options, null] }]; + const newRankings20 = [{ voter_id: 'blah', ranking: [...election.options, 1] }]; + const newRankings21 = [{ voter_id: 'blah', ranking: [...election.options, ...election.options] }]; + const newRankings22 = [{ voter_id: 'blah', ranking: [...election.options, election.options[0]] }]; + const newRankings23 = [ + { voter_id: 'blah', ranking: election.options }, + { voter_id: 'blah', ranking: election.options } + ]; + const newRankings24 = [{ voter_id: 'blah', ranking: election.options, extra: 'bad' }]; + + expect(Backend.validateRankingsData(election._id, newRankings1)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings2)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings3)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings4)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings5)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings6)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings7)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings8)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings9)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings10)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings11)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings12)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings13)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings14)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings15)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings16)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings17)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings18)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings19)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings20)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings21)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings22)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings23)).toReject(); + expect(Backend.validateRankingsData(election._id, newRankings24)).toReject(); + }); + }); + + describe('::addToRequestLog', () => { + it('adds request to log as expected', async () => { + const req1 = { + headers: { 'x-forwarded-for': '9.9.9.9' }, + method: 'POST', + url: 'http://fake.com/api/route/path1' + } as unknown as NextApiRequest; + + const req2 = { + headers: { + 'x-forwarded-for': '8.8.8.8', + 'key': Backend.NULL_KEY + }, + method: 'GET', + url: 'http://fake.com/api/route/path2' + } as unknown as NextApiRequest; + + const res1 = { statusCode: 1111 } as NextApiResponse; + const res2 = { statusCode: 2222 } as NextApiResponse; + + const now = Date.now(); + const _now = Date.now; + Date.now = () => now; + + await Backend.addToRequestLog(req1, res1); + await Backend.addToRequestLog(req2, res2); + + Date.now = _now; + + const reqlog = await (await getDb()).collection('request-log'); + + expect(await reqlog.findOne({ statusCode: 1111 })).toEqual({ + ip: '9.9.9.9', + route: '/api/route/path1', + method: 'POST', + time: now, + response: 1111 + }); + + expect(await reqlog.findOne({ statusCode: 2222 })).toEqual({ + ip: '8.8.8.8', + key: Backend.NULL_KEY, + route: '/api/route/path2', + method: 'GET', + time: now, + response: 2222 + }); + }); + }); + + describe('::isRateLimited', () => { + it('returns true if ip or key are rate limited', async () => { + const req1 = { + headers: { 'x-forwarded-for': '1.2.3.4' }, + method: 'POST', + url: 'http://fake.com/api/route/path1' + } as unknown as NextApiRequest; + + const req2 = { + headers: { + 'x-forwarded-for': '8.8.8.8', + 'key': Backend.NULL_KEY + }, + method: 'GET', + url: 'http://fake.com/api/route/path2' + } as unknown as NextApiRequest; + + const req3 = { + headers: { + 'x-forwarded-for': '1.2.3.4', + 'key': 'fake-key' + }, + method: 'POST', + url: 'http://fake.com/api/route/path1' + } as unknown as NextApiRequest; + + expect(await Backend.isRateLimited(req1)).toEqual(true); + expect(await Backend.isRateLimited(req2)).toEqual(true); + expect(await Backend.isRateLimited(req3)).toEqual(true); + }); + + it('returns false iff both ip and key (if provided) are not rate limited', async () => { + const req1 = { + headers: { 'x-forwarded-for': '1.2.3.5' }, + method: 'POST', + url: 'http://fake.com/api/route/path1' + } as unknown as NextApiRequest; + + const req2 = { + headers: { + 'x-forwarded-for': '8.8.8.8', + 'key': 'fake-key' + }, + method: 'GET', + url: 'http://fake.com/api/route/path2' + } as unknown as NextApiRequest; + + expect(await Backend.isRateLimited(req1)).toEqual(false); + expect(await Backend.isRateLimited(req2)).toEqual(false); + }); + }); + + describe('::isDueForContrivedError', () => { + it('returns true after REQUESTS_PER_CONTRIVED_ERROR invocations', async () => { + const rate = getEnv().REQUESTS_PER_CONTRIVED_ERROR; + expect.assertions(rate); + + [...Array(rate)].forEach((_, i) => + expect(Backend.isDueForContrivedError()).toEqual(i == rate - 1 ? true : false)); + }); + }); +}); + diff --git a/src/backend/__test__/db.ts b/src/backend/__test__/db.ts new file mode 100644 index 0000000..29f4882 --- /dev/null +++ b/src/backend/__test__/db.ts @@ -0,0 +1,241 @@ +import { MongoClient, Db, ObjectId } from 'mongodb' +import { NULL_KEY } from 'universe/backend' +import { initialize, getDb, setDb } from 'universe/backend/db' +import { getEnv } from 'universe/backend/env' +import { populateEnv } from 'universe/dev-utils' +import * as Time from 'multiverse/relative-random-time' +import shuffle from 'fast-shuffle' +import randomInt from 'random-int' +import uniqueRandomArray from 'unique-random-array' + +import type { + InternalElection, + ElectionRankings, + ApiKey, + RequestLogEntry, + LimitedEntry +} from 'types/global' + +populateEnv(); + +export type DummyDbData = { + keys: ApiKey[]; + elections: InternalElection[]; +}; + +type InternalElectionSubset = Pick; + +const injectData = (ob: InternalElectionSubset, fn: (obj: InternalElection) => void): InternalElection => { + const election = ob as InternalElection; + fn(election); + return election; +}; + +const expandToMaxPageLength = (elections: InternalElection[]): InternalElection[] => { + const maxLimit = getEnv().MAX_LIMIT; + + while(elections.length > 0 && elections.length < maxLimit) + elections.push({... (elections.length % 2 ? elections[0] : (elections[1] || elections[0])) }); + + (elections = elections.slice(0, maxLimit)).forEach(election => { + election._id = new ObjectId(); + election.title += ` x-gen#${randomInt(maxLimit)}`; + }); + + return elections; +}; + +export const unhydratedDummyDbData: DummyDbData = { + keys: [ + { + owner: 'chapter1', + key: 'a0a49b61-83a7-4036-b060-213784b4997c' + }, + { + owner: 'chapter2', + key: '5db4c4d3-294a-4086-9751-f3fce82d11e4' + }, + ], + elections: [ + expandToMaxPageLength([ + injectData({ + title: 'My election #1', + description: 'My demo election!', + options: [ 'Vanilla', 'Chocolate', 'Strawberry' ], + owner: NULL_KEY, + deleted: false + }, (o) => { + o.created = Time.farPast(); + o.opens = Time.farPast({ after: o.created }); + o.closes = Time.farPast({ after: o.opens }); + }), + injectData({ + title: 'My election #2', + description: 'My demo election!', + options: [ 'Vanilla', 'Chocolate', 'Strawberry' ], + owner: NULL_KEY, + deleted: true + }, (o) => { + o.created = Time.farFuture(); + o.opens = Time.farFuture({ after: o.created }); + o.closes = Time.farFuture({ after: o.opens }); + }) + ]), + expandToMaxPageLength([ + injectData({ + title: 'My election #3', + description: 'My demo election!', + options: [ 'Red', 'Green', 'Blue', 'Yellow' ], + owner: 'a0a49b61-83a7-4036-b060-213784b4997c', + deleted: false + }, (o) => { + o.created = Time.farPast(); + o.opens = Time.nearFuture(); + o.closes = Time.farFuture(); + }), + injectData({ + title: 'My election #4', + description: 'My demo election!', + options: [ 'Chalk', 'Dye', 'Egg', 'Foam', 'Grease', 'Hand' ], + owner: 'a0a49b61-83a7-4036-b060-213784b4997c', + deleted: false + }, (o) => { + o.created = Time.nearFuture(); + o.opens = Time.nearFuture({ after: o.created }); + o.closes = Time.nearFuture({ after: o.opens }); + }), + injectData({ + title: 'My election #5', + description: 'My demo election!', + options: [ 'Walking Dead', 'Red Dead', 'Dead Eye' ], + owner: '5db4c4d3-294a-4086-9751-f3fce82d11e4', + deleted: false + }, (o) => { + o.created = Time.nearPast(); + o.opens = Time.nearPast({ after: o.created }); + o.closes = Time.nearPast({ after: o.opens }); + }) + ]), + expandToMaxPageLength([ + injectData({ + title: 'My election #6', + description: 'My demo election again!', + options: [ 'Red', 'Green', 'Blue', 'Yellow', 'Orange', 'Purple' ], + owner: '5db4c4d3-294a-4086-9751-f3fce82d11e4', + deleted: false + }, (o) => { + o.created = Time.nearPast(); + o.opens = Time.nearPast({ after: o.created }); + o.closes = Time.nearFuture(); + }), + injectData({ + title: 'My election #7', + description: 'Best election bigly!', + options: [ 'Bigly', 'Bigliest', 'Winning', 'Orange', 'Hair', 'Insane' ], + owner: '5db4c4d3-294a-4086-9751-f3fce82d11e4', + deleted: false + }, (o) => { + o.created = Time.nearPast(); + o.opens = Time.nearPast({ after: o.created }); + o.closes = Time.farFuture(); + }) + ]), + ].flat() +}; + +// TODO: not idempotent; elections will be duplicated if called twice +export async function hydrateDb(db: Db, data: DummyDbData): Promise { + const newData = { ...data }; + + // Update keys + if(newData.keys) { + const keysDb = db.collection('keys').initializeUnorderedBulkOp(); + + newData.keys.forEach(keyRecord => keysDb.find({ key: keyRecord.key }).upsert().updateOne(keyRecord)); + await keysDb.execute(); + } + + // Push new elections + if(newData.elections) { + const electionsDb = db.collection('elections'); + const rankingsDb = db.collection('rankings'); + + await electionsDb.insertMany(newData.elections); + const getArrayLength = uniqueRandomArray([0, 1, 2, randomInt(3, 6), randomInt(10, 20), 100, 1000]); + + await rankingsDb.insertMany(newData.elections.map(election => ({ + election_id: election._id, + rankings: [...Array(getArrayLength())].map((_, id) => ({ + voter_id: randomInt(id * 3, (id + 1) * 3 - 1).toString(), + ranking: shuffle(election.options) + })) + }))); + } + + // Push new requests to the log and update limited-mview accordingly + const requestLogDb = db.collection('request-log'); + const mviewDb = db.collection('limited-mview'); + + await requestLogDb.insertMany([...Array(20)].map((_, ndx) => ({ + _id: new ObjectId(), + ip: '1.2.3.4', + key: ndx % 2 ? null : NULL_KEY, + method: ndx % 3 ? 'GET' : 'POST', + route: 'fake/route', + time: Date.now(), + response: 200, + }))); + + await mviewDb.insertMany([ + { _id: new ObjectId(), ip: '1.2.3.4', until: Date.now() + 10**5 }, + { _id: new ObjectId(), ip: '5.6.7.8', until: Date.now() + 10**6 }, + { _id: new ObjectId(), key: NULL_KEY, until: Date.now() + 10**7 } + ]); + + return newData; +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function setupJest() { + let connection: MongoClient; + let hydratedData: DummyDbData; + + beforeAll(async () => { + connection = await MongoClient.connect(getEnv().MONGODB_TEST_URI, { useUnifiedTopology: true }); + const db = connection?.db(); + + if(!db) + throw new Error('unable to connect to database (1)'); + + setDb(db); + }); + + beforeEach(async () => { + const dbName = getEnv().MONGODB_TEST_URI.split('/').slice(-1)[0]; + + await (await getDb()).dropDatabase(); + + if(!dbName) + throw new Error('database name resolution failed in Jest test'); + + const db = connection.db(dbName); + + if(!db) + throw new Error('unable to connect to database (2)'); + + await initialize(db, { reinitialize: true }); + hydratedData = await hydrateDb(db, unhydratedDummyDbData); + + setDb(db); + }); + + afterAll(async () => { + await new Promise(ok => setTimeout(() => (connection.isConnected() && connection.close(), ok()), 750)); + }); + + return { + getDb, + getConnection: () => connection, + getHydratedData: () => hydratedData + }; +} diff --git a/src/backend/__test__/middleware.test.ts b/src/backend/__test__/middleware.test.ts new file mode 100644 index 0000000..2550af1 --- /dev/null +++ b/src/backend/__test__/middleware.test.ts @@ -0,0 +1,9 @@ +import { setupJest } from './db' + +const { getDb } = setupJest(); + +describe('universe/backend/middleware', () => { + describe('::handleEndpoint', () => { + test.todo('need to test that middleware enforces: authentication, rate limiting, contrived error rate, bad method, rejecting requests that are too big'); + }); +}); diff --git a/src/backend/__tests__/backend.test.ts b/src/backend/__tests__/backend.test.ts deleted file mode 100644 index a9e3c7f..0000000 --- a/src/backend/__tests__/backend.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { MongoClient } from 'mongodb' -import { MongoMemoryServer } from 'mongodb-memory-server' -import { initialize } from 'universe/initialize-test-db' - -import { - setDB, -} from 'universe/backend' - -import type { Db } from 'mongodb' - -let connection: MongoClient; -let db: Db; -const server = new MongoMemoryServer(); - -beforeAll(async () => { - if(!connection || !db ) { - connection = await MongoClient.connect(await server.getConnectionString(), { useUnifiedTopology: true }); - db = connection?.db(); - - if(!db) - throw new Error('unable to connect to database'); - - setDB(db); - } -}); - -beforeEach(async () => { - initialize(db); -}); - -afterAll(async () => { - connection && connection.close(); - server.stop(); -}); - -describe('xyz', () => { - it('abc', async () => { - //expect((await getRawDB()).users[defaultNextId].password).toBe('t'); - }); -}); diff --git a/src/backend/db.ts b/src/backend/db.ts new file mode 100644 index 0000000..8e59463 --- /dev/null +++ b/src/backend/db.ts @@ -0,0 +1,46 @@ +import { MongoClient, Db } from 'mongodb' +import { getEnv } from 'universe/backend/env'; + +let db: Db | null = null; + +/** + * Used to lazily create the database once on-demand instead of immediately when + * the app runs. + */ +export async function getDb(): Promise { + db = db || (await MongoClient.connect(getEnv().MONGODB_URI, { useUnifiedTopology: true })).db(); + return db; +} + +/** + * Used for testing purposes. Sets the global db instance to something else. + */ +export function setDb(newDB: Db): void { db = newDB; } + +// TODO: document that this function is idempotent and can be called on +// TODO: conformant databases that already have the appropriate structure +// TODO: without worry of data loss unless reinitialize == true. +// ! reinitialize=true => will kill whatever is currently in the database! +export async function initialize(db: Db, { reinitialize }: { reinitialize?: boolean } = {}): Promise { + // TODO: add validation rules during createCollection phase + // TODO: (including special 0 root key not accepted in keys or in API) + // TODO: also make an index over key in keys (if not exists) + + if(reinitialize) { + await Promise.allSettled([ + db.dropCollection('keys'), + db.dropCollection('elections'), + db.dropCollection('rankings'), + db.dropCollection('request-log'), + db.dropCollection('limited-mview') + ]); + } + + await Promise.all([ + db.createCollection('keys'), + db.createCollection('elections'), + db.createCollection('rankings'), + db.createCollection('request-log', { capped: true, size: 1000000, max: 10000 }), + db.createCollection('limited-mview'), + ]); +} diff --git a/src/backend/env.ts b/src/backend/env.ts new file mode 100644 index 0000000..2f77d5c --- /dev/null +++ b/src/backend/env.ts @@ -0,0 +1,59 @@ +import { isNumber } from 'util' +import { parse as parseAsBytes } from 'bytes' +import isServer from 'multiverse/is-server-side' + +export function getEnv(silent=false) { + const env = { + NODE_ENV: process.env.NODE_ENV || process.env.BABEL_ENV || process.env.APP_ENV || 'unknown', + MONGODB_URI: (process.env.MONGODB_URI || '').toString(), + MONGODB_TEST_URI: (process.env.MONGODB_TEST_URI || '').toString(), + MAX_LIMIT: parseInt(process.env.MAX_LIMIT ?? '-Infinity'), + LIMIT_OVERRIDE: parseInt(process.env.LIMIT_OVERRIDE ?? '-Infinity'), + DISABLE_RATE_LIMITS: !!process.env.DISABLE_RATE_LIMITS && process.env.DISABLE_RATE_LIMITS !== 'false', + LOCKOUT_ALL_KEYS: !!process.env.LOCKOUT_ALL_KEYS && process.env.LOCKOUT_ALL_KEYS !== 'false', + DISALLOW_WRITES: !!process.env.DISALLOW_WRITES && process.env.DISALLOW_WRITES !== 'false', + REQUESTS_PER_CONTRIVED_ERROR: parseInt(process.env.REQUESTS_PER_CONTRIVED_ERROR ?? '-Infinity'), + MAX_OPTIONS_PER_ELECTION: parseInt(process.env.MAX_OPTIONS_PER_ELECTION || '-Infinity'), + MAX_RANKINGS_PER_ELECTION: parseInt(process.env.MAX_RANKINGS_PER_ELECTION || '-Infinity'), + MAX_CONTENT_LENGTH_BYTES: parseAsBytes(process.env.MAX_CONTENT_LENGTH_BYTES || '-Infinity'), + }; + + const _mustBeGtZero = [ + env.MAX_LIMIT, + env.LIMIT_OVERRIDE, + env.REQUESTS_PER_CONTRIVED_ERROR, + env.MAX_OPTIONS_PER_ELECTION, + env.MAX_RANKINGS_PER_ELECTION, + env.MAX_CONTENT_LENGTH_BYTES + ]; + + if(!silent && !isServer() && env.NODE_ENV == 'development') { + /* eslint-disable no-console */ + console.warn('--- !APP INITIALIZED IN DEVELOPMENT MODE! ---'); + // console.info(`--- + // NODE_ENV: ${NODE_ENV} + // env.MONGODB_URI: ${env.MONGODB_URI} + // env.MONGODB_TEST_URI: ${env.MONGODB_TEST_URI} + // env.MAX_LIMIT: ${env.MAX_LIMIT} + // env.LIMIT_OVERRIDE: ${env.LIMIT_OVERRIDE} + // env.DISABLE_RATE_LIMITS: ${env.DISABLE_RATE_LIMITS} + // env.LOCKOUT_ALL_KEYS: ${env.LOCKOUT_ALL_KEYS} + // env.DISALLOW_WRITES: ${env.DISALLOW_WRITES} + // env.REQUESTS_PER_CONTRIVED_ERROR: ${env.REQUESTS_PER_CONTRIVED_ERROR} + // env.MAX_OPTIONS_PER_ELECTION: ${env.MAX_OPTIONS_PER_ELECTION} + // env.MAX_RANKINGS_PER_ELECTION: ${env.MAX_RANKINGS_PER_ELECTION} + // env.MAX_CONTENT_LENGTH_BYTES: ${env.MAX_CONTENT_LENGTH_BYTES} + // ---`); + /* eslint-enable no-console */ + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + if(env.NODE_ENV == 'unknown' || (isServer() && env.MONGODB_URI === '') || + (env.NODE_ENV == 'test' && env.MONGODB_TEST_URI === '') || + _mustBeGtZero.some(v => !isNumber(v) || v < 0)) { + throw new Error('illegal environment detected, check environment variables'); + } + + return env; +} diff --git a/src/backend/error.ts b/src/backend/error.ts new file mode 100644 index 0000000..c0c13b2 --- /dev/null +++ b/src/backend/error.ts @@ -0,0 +1,53 @@ +export class AppError extends Error {} +export class GuruMeditationError extends AppError {} +export class ValidationError extends AppError {} + +export class UpsertFailedError extends AppError { + constructor(...args: any[]) { + const message = args[0] || 'data upsert failed'; + super(...[message, ...args.slice(1)]); + } +} + +export class NotFoundError extends AppError { + constructor(...args: any[]) { + const message = !!args[0] + ? `item ${args[0]} does not exist or was not found` + : 'item or resource was not found'; + + super(...[message, ...args.slice(1)]); + } +} + +export class TimeTypeError extends UpsertFailedError { + constructor(...args: any[]) { + const message = args[0] || 'invalid `opens` and/or `closes` properties (bad time data?)'; + super(...[message, ...args.slice(1)]); + } +} + +export class IdTypeError extends AppError { + constructor(...args: any[]) { + const message = !!args[0] + ? `expected valid ObjectId instance, got "${args[0]}" instead` + : 'invalid ObjectId encountered'; + + super(...[message, ...args.slice(1)]); + } +} + +export class ApiKeyTypeError extends AppError { + constructor(...args: any[]) { + super('invalid API key encountered'); + } +} + +export class LimitTypeError extends AppError { + constructor(...args: any[]) { + const message = typeof args[0] == 'number' + ? `\`limit\` must be a number, got ${args[0]} instead` + : 'invalid `limit` encountered'; + + super(...[message, ...args.slice(1)]); + } +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 32002e7..15f2857 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,20 +1,210 @@ -/* @flow */ +import { ObjectId } from 'mongodb' +import { getEnv } from 'universe/backend/env' +import { getDb } from 'universe/backend/db' +import { + LimitTypeError, + IdTypeError, + ApiKeyTypeError, + TimeTypeError, + NotFoundError, + UpsertFailedError, + GuruMeditationError, +} from 'universe/backend/error' -import { MongoClient } from 'mongodb' -import type { Db } from 'mongodb' +import type { Metadata, PublicElection, InternalElection, NewElection, PatchElection } from 'types/global' -let db: Db | null = null; +export type UpsertElectionParams = { + election: NewElection | PatchElection; + electionId?: ObjectId; + key: string; +}; -/** - * Used to lazily create the database once on-demand instead of immediately when - * the app runs. Not exported. - */ -export async function getDB(): Promise { - db = db || (await MongoClient.connect(process.env.MONGODB_URI || '', { useUnifiedTopology: true })).db(); - return db; +export const NULL_KEY = '00000000-0000-0000-0000-000000000000'; +export const DEFAULT_RESULT_LIMIT = 15; + +export async function getElectionMetadata() { + const now = Date.now(); + + const meta: Metadata = { + upcomingElections: 0, + openElections: 0, + closedElections: 0 + }; + + return { + ...meta, + ...await (await getDb()).collection('elections').aggregate([ + { + $group: { + _id: null, + + upcomingElections: { + $sum: { + $cond: { + if: { $and: [{ $gt: ['$closes', now] }, { $gt: ['$opens', now] }] }, then: 1, else: 0 + } + } + }, + + openElections: { + $sum: { + $cond: { + if: { $and: [{ $gt: ['$closes', now] }, { $lte: ['$opens', now] }] }, then: 1, else: 0 + } + } + }, + + closedElections: { + $sum: { $cond: { if: { $lte: ['$closes', now] }, then: 1, else: 0 }} + } + } + }, + { $project: { _id: false }} + ]).next() + } as Metadata; +} + +export async function getPublicElections(opts: { limit?: number; after?: ObjectId | null; key: string }) { + const { limit, after, key } = { limit: DEFAULT_RESULT_LIMIT, after: null, ...opts }; + + if(typeof limit != 'number' || limit <= 0 || limit > getEnv().MAX_LIMIT) + throw new LimitTypeError(limit); + + if(after && !(after instanceof ObjectId)) + throw new IdTypeError(after); + + if(!key || typeof key != 'string') + throw new ApiKeyTypeError(); + + return await (await getDb()).collection('elections').aggregate([ + ...(after ? [{ $match: { _id: { $gt: new ObjectId(after) }}}] : []), + { $limit: limit }, + { + $addFields: { + election_id: '$_id', + owned: key ? { $cond: { if: { $eq: ['$owner', key] }, then: true, else: false }} : false + } + }, + { $project: { _id: false, owner: false }}, + ]); +} + +export async function getPublicElection(opts: { electionId: ObjectId; key: string }) { + const { electionId, key } = opts; + + if(!key || typeof key != 'string') + throw new ApiKeyTypeError(); + + if(!(electionId instanceof ObjectId)) + throw new IdTypeError(electionId); + + const elections = await (await (await getDb()).collection('elections').aggregate([ + { $match: { _id: electionId }}, + { $limit: 1 }, + { + $addFields: { + election_id: '$_id', + owned: key ? { $cond: { if: { $eq: ['$owner', key] }, then: true, else: false }} : false + } + }, + { $project: { _id: false, owner: false }}, + ])).toArray(); + + if(elections.length <= 0) + throw new NotFoundError(); + + return elections[0]; +} + +export async function getInternalElection(electionId: ObjectId) { + if(!(electionId instanceof ObjectId)) + throw new IdTypeError(electionId); + + const election = await (await getDb()).collection('elections').find({ _id: electionId }).next(); + + if(!election) + throw new NotFoundError(electionId); + + return election; +} + +export async function doesElectionExist(electionId: ObjectId) { + if(!(electionId instanceof ObjectId)) + throw new IdTypeError(electionId); + + return !!await (await getDb()).collection('elections').find({ _id: electionId }).limit(1).count(); + +} + +export async function upsertElection(opts: UpsertElectionParams) { + const { election: electionData, electionId, key } = opts; + + if(electionId && !(electionId instanceof ObjectId)) + throw new IdTypeError(electionId); + + if(!key || typeof key != 'string') + throw new ApiKeyTypeError(); + + const { title, description, options, opens, closes, ...rest } = electionData; + + const newData: Partial = { + _id: electionId || new ObjectId(), + }; + + if(!electionId) { + if(!title || !opens || !closes) + throw new UpsertFailedError(); + + newData.title = title; + newData.opens = opens; + newData.closes = closes; + newData.description = description || ''; + newData.options = options || []; + newData.deleted = false; + newData.created = Date.now(); + newData.owner = key; + } + + else { + if(!doesElectionExist(electionId)) + throw new NotFoundError(electionId); + + const { deleted } = rest as Partial; + + if((opens && !closes) || (closes && !opens)) + throw new TimeTypeError('when updating `opens` or `closes` properties, both must be modified together'); + + title && (newData.title = title); + opens && (newData.opens = opens); + closes && (newData.closes = closes); + description && (newData.description = description); + options && (newData.options = options); + deleted && (newData.deleted = deleted); + } + + if((opens && closes) && (opens >= closes || (newData.created && opens <= newData.created))) + throw new TimeTypeError(); + + const result = await (await getDb()).collection('elections').updateOne( + { _id: newData._id }, + { + ...(newData.title ? { $set: { title: newData.title }} : {}), + ...(newData.opens ? { $set: { opens: newData.opens }} : {}), + ...(newData.closes ? { $set: { closes: newData.closes }} : {}), + ...(newData.description ? { $set: { description: newData.description }} : {}), + ...(newData.options ? { $set: { options: newData.options }} : {}), + ...(newData.deleted ? { $set: { deleted: newData.deleted }} : {}), + ...(newData.created ? { $set: { created: newData.created }} : {}), + ...(newData.owner ? { $set: { owner: newData.owner }} : {}), + }, + { upsert: true } + ); + + if(!result.upsertedCount && !result.matchedCount) + throw new GuruMeditationError(); + + result.upsertedCount && (newData._id = result.upsertedId._id); + + return newData; } -/** - * Used for testing purposes. Sets the global db instance to something else. - */ -export function setDB(newDB: Db): void { db = newDB; } diff --git a/src/backend/middleware.ts b/src/backend/middleware.ts index e5b6dad..46679c8 100644 --- a/src/backend/middleware.ts +++ b/src/backend/middleware.ts @@ -1,12 +1,17 @@ -/* @flow */ - -import { isAuthed } from 'multiverse/simple-auth-session' +import { sendHttpErrorResponse } from 'multiverse/respond' import type { NextApiResponse } from 'next' -import type { NextParamsRRWithSession } from 'multiverse/simple-auth-session' +import type { NextParamsRR, GenericObject } from 'types/global' + +export type GenericHandlerParams = NextParamsRR & { methods: string[] }; +export type AsyncHandlerCallback = (params: NextParamsRR) => Promise; -export type GenericHandlerParams = NextParamsRRWithSession & { methods: string[] }; -export type AsyncHandlerCallback = (params: NextParamsRRWithSession) => Promise; +export function sendHttpContrivedError(res: NextApiResponse, responseJson: GenericObject) { + sendHttpErrorResponse(res, 555, { + error: '(note: do not report this contrived error)', + ...responseJson + }); +} /** * Generic middleware to handle any api endpoint. You can give it an empty async diff --git a/src/dev-utils.js b/src/dev-utils.js index dc50633..b15208f 100644 --- a/src/dev-utils.js +++ b/src/dev-utils.js @@ -11,18 +11,16 @@ const expectedEnvVariables = pkg.expectedEnvVariables || []; if(!Array.isArray(expectedEnvVariables)) throw new Error('expectedEnvVariables in package.json must be an array'); +/** + * @param {string} variable + */ const throwEnvError = variable => { throw new Error(`${variable} is not defined. Copy dist.env --> .env or or define ${variable} in the environment.`); }; module.exports = { populateEnv() { - /* const conf = */ dotenv.config(); - - // ? Parse the .env file at the project root or throw an error if it does - // ? not exist or if parsing fails for some other reason - //if(!conf || !conf.parsed) - // throw new Error('Failed to parse an .env configuration. Copy dist.env --> .env or run `now env pull`?'); + dotenv.config(); // ? Loop over the values in expectedEnvVariables from package.json to // ? ensure they exist and are strings. If this is not the case, throw an @@ -34,8 +32,7 @@ module.exports = { // ? Resolve the true node/application environment mode --> NODE_ENV // ? Recognized values: development, test, production - process.env.NODE_ENV = process.env.NODE_ENV || process.env.BABEL_ENV || process.env.APP_ENV || 'unknown'; // eslint-disable-next-line no-console - process.env.NODE_ENV === 'unknown' && console.warn('WARNING: the application environment resolved to "unknown"!'); + !process.env.NODE_ENV && console.warn(`WARNING: process.env.NODE_ENV resolved to "${process.env.NODE_ENV}"!`); } }; diff --git a/src/initialize-db.ts b/src/initialize-db.ts deleted file mode 100644 index 765d4c4..0000000 --- a/src/initialize-db.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Db } from 'mongodb' -import { produce as mutate } from 'immer' -import shuffle from 'fast-shuffle' -import randomInt from 'random-int' -import uniqueRandomArray from 'unique-random-array' - -import type { Immutable } from 'immer' -import type { InternalElection, ElectionRankings, Rankings } from 'types/global' - -export const NULL_KEY = '00000000-0000-0000-0000-000000000000'; - -export type InitialData = { - meta?: { - upcomingElections: number; - openElections: number; - closedElections: number; - }; - - keys?: { - owner: string; - key: string; - }[]; - - elections?: InternalElection[]; - - votes?: { [electionId: string]: Rankings}; -}; - -// TODO: document that this function is idempotent and can be called on -// TODO: conformant databases that already have the appropriate structure -// TODO: without worry of data loss. -// ! reinitialize=true => will kill whatever is currently in the database! -export async function initialize(db: Db, { reinitialize }: { reinitialize?: boolean } = {}): Promise { - // TODO: add validation rules during createCollection phase - // TODO: (including special 0 root key not accepted in keys or in API) - // TODO: also make an index over key in keys (if not exists) - - if(reinitialize) { - await Promise.all([ - db.dropCollection('meta'), - db.dropCollection('keys'), - db.dropCollection('elections'), - db.dropCollection('votes'), - ]); - } - - // ? The meta collection should be a singleton. The initialize() function - // ? must be idempotent. To satisfy both invariants, we only update a new - // ? metadata document if one does not already exist. - await Promise.all([ - (await db.createCollection('meta')).updateOne( - {}, - { $setOnInsert: { - upcomingElections: 0, - openElections: 0, - closedElections: 0, - }}, - { upsert: true } - ), - db.createCollection('keys'), - db.createCollection('elections'), - db.createCollection('votes'), - ]); -} - -// TODO: not idempotent; elections, votes will be duplicated and metadata will -// TODO: be reset (if present) if called twice -export async function hydrate(db: Db, data: Immutable): Promise> { - let newData = null; - - // Update meta - if(data.meta) { - const metaDb = db.collection('meta'); - await metaDb.updateOne({}, { $set: data.meta }); - } - - // Update keys - if(data.keys) { - const keysDb = db.collection('keys').initializeUnorderedBulkOp(); - - data.keys.forEach(keyRecord => keysDb.find({ key: keyRecord.key }).upsert().updateOne(keyRecord)); - await keysDb.execute(); - } - - // Push new elections - if(data.elections) { - const electionsDb = db.collection('elections'); - const votesDb = db.collection('votes'); - - newData = mutate(data, async draft => { - const result = await electionsDb.insertMany(draft.elections || []); - const getArrayLength = uniqueRandomArray([0, 1, 2, randomInt(3, 6), randomInt(10, 20), 100, 1000]); - - await votesDb.insertMany(Object.entries(result.insertedIds).map(([ index, election_id ]) => ({ - election_id, - rankings: [...Array(getArrayLength())].map((_, id) => ({ - voter_id: (id + 1).toString(), - ranking: shuffle(draft.elections?.[(index as unknown) as number].options || []) - })) - }))); - }); - } - - return newData || data; -} diff --git a/src/initialize-test-db.ts b/src/initialize-test-db.ts deleted file mode 100644 index a40178b..0000000 --- a/src/initialize-test-db.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Db } from 'mongodb' -import { produce as mutate } from 'immer' -import * as Time from 'multiverse/relative-random-time' -import { initialize as originalInitialize, hydrate, NULL_KEY } from 'universe/initialize-db' - -import type { InitialData } from 'universe/initialize-db' -import type { Immutable } from 'immer' -import type { GenericObject, InternalElection } from 'types/global' - -const o = (ob: GenericObject, fn: (obj: InternalElection) => void): InternalElection => { - const election = ob as InternalElection; - fn(election); - return election; -}; - -export const initialData: Immutable = mutate({}, () => ({ - meta: { - upcomingElections: 1, - openElections: 2, - closedElections: 3, - }, - keys: [ - { - owner: 'chapter1', - key: 'a0a49b61-83a7-4036-b060-213784b4997c' - }, - { - owner: 'chapter2', - key: '5db4c4d3-294a-4086-9751-f3fce82d11e4' - }, - ], - elections: [ - o({ - title: 'My election #1', - description: 'My demo election!', - options: [ 'Vanilla', 'Chocolate', 'Strawberry' ], - owner: NULL_KEY, - deleted: false - }, (o) => { - o.created = Time.farPast(); - o.opens = Time.farPast({ after: o.created }); - o.closes = Time.farPast({ after: o.opens }); - }), - o({ - title: 'My election #2', - description: 'My demo election!', - options: [ 'Vanilla', 'Chocolate', 'Strawberry' ], - owner: NULL_KEY, - deleted: true - }, (o) => { - o.created = Time.farFuture(); - o.opens = Time.farFuture({ after: o.created }); - o.closes = Time.farFuture({ after: o.opens }); - }), - o({ - title: 'My election #3', - description: 'My demo election!', - options: [ 'Red', 'Green', 'Blue', 'Yellow' ], - owner: 'a0a49b61-83a7-4036-b060-213784b4997c', - deleted: false - }, (o) => { - o.created = Time.farPast(); - o.opens = Time.nearFuture(); - o.closes = Time.farFuture(); - }), - o({ - title: 'My election #4', - description: 'My demo election!', - options: [ 'Chalk', 'Dye', 'Egg', 'Foam', 'Grease', 'Hand' ], - owner: 'a0a49b61-83a7-4036-b060-213784b4997c', - deleted: false - }, (o) => { - o.created = Time.nearFuture(); - o.opens = Time.nearFuture({ after: o.created }); - o.closes = Time.nearFuture({ after: o.opens }); - }), - o({ - title: 'My election #5', - description: 'My demo election!', - options: [ 'Walking Dead', 'Red Dead', 'Dead Eye' ], - owner: '5db4c4d3-294a-4086-9751-f3fce82d11e4', - deleted: false - }, (o) => { - o.created = Time.nearPast(); - o.opens = Time.nearPast({ after: o.created }); - o.closes = Time.nearPast({ after: o.opens }); - }), - o({ - title: 'My election #6', - description: 'My demo election again!', - options: [ 'Red', 'Green', 'Blue', 'Yellow', 'Orange', 'Purple' ], - owner: '5db4c4d3-294a-4086-9751-f3fce82d11e4', - deleted: false - }, (o) => { - o.created = Time.nearPast(); - o.opens = Time.nearPast({ after: o.created }); - o.closes = Time.nearFuture(); - }), - o({ - title: 'My election #7', - description: 'Best election bigly!', - options: [ 'Bigly', 'Bigliest', 'Winning', 'Orange', 'Hair', 'Insane' ], - owner: '5db4c4d3-294a-4086-9751-f3fce82d11e4', - deleted: false - }, (o) => { - o.created = Time.nearPast(); - o.opens = Time.nearPast({ after: o.created }); - o.closes = Time.farFuture(); - }), - ] -})); - -export async function initialize(db: Db): Promise { - await originalInitialize(db, { reinitialize: true }); - await hydrate(db, initialData); -} diff --git a/src/pages/api/v1/__test__/.gitkeep b/src/pages/api/v1/__test__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/api/v1/__test__/election.test.ts b/src/pages/api/v1/__test__/election.test.ts new file mode 100644 index 0000000..d12f5af --- /dev/null +++ b/src/pages/api/v1/__test__/election.test.ts @@ -0,0 +1,10 @@ +import { setupJest, unhydratedDummyDbData } from 'universe/backend/__test__/db' + +setupJest(); + +describe('api/v1/election', () => { + describe('::handleEndpoint', () => { + test.todo('need to test that endpoint returns data as expected, no unauth, handles voters endpoint too, validates data on POST, error if election DNE, errors when expected, disallows too big, public vs private data, rate limits, adjustable error rate, logging db is working, etc'); + }); + //MAX_CONTENT_LENGTH_BYTES REQUESTS_PER_CONTRIVED_ERROR DISALLOW_WRITES LOCKOUT_ALL_KEYS DISABLE_RATE_LIMITS +}); diff --git a/src/pages/api/v1/__test__/elections.test.ts b/src/pages/api/v1/__test__/elections.test.ts new file mode 100644 index 0000000..a9eab49 --- /dev/null +++ b/src/pages/api/v1/__test__/elections.test.ts @@ -0,0 +1,10 @@ +import { setupJest, unhydratedDummyDbData } from 'universe/backend/__test__/db' + +setupJest(); + +describe('api/v1/elections', () => { + describe('::handleEndpoint', () => { + test.todo('need to test that endpoint returns data as expected, no unauth, handles pagination properly, returns in LIFO order by default, works with query parameters, errors if query parameters are provided during POST, validates data on POST, errors when expected, limits larger than 50 (or w/e max is) trigger http400, offsets that are too large return nothing without error, disallows too big, public vs private data, rate limits, adjustable error rate, logging db is working, pagination limited to 50, returns empty array when limit = 0 but does not trigger error by passing that 0 around, etc'); + }); + //MAX_CONTENT_LENGTH_BYTES REQUESTS_PER_CONTRIVED_ERROR DISALLOW_WRITES LOCKOUT_ALL_KEYS DISABLE_RATE_LIMITS MAX_LIMIT LIMIT_OVERRIDE +}); diff --git a/src/pages/api/v1/__test__/meta.test.ts b/src/pages/api/v1/__test__/meta.test.ts new file mode 100644 index 0000000..7e9d1ce --- /dev/null +++ b/src/pages/api/v1/__test__/meta.test.ts @@ -0,0 +1,10 @@ +import { setupJest, unhydratedDummyDbData } from 'universe/backend/__test__/db' + +setupJest(); + +describe('api/v1/meta', () => { + describe('::handleEndpoint', () => { + test.todo('need to test that endpoint returns data as expected, no unauth, errors when expected, disallows too big, rate limits, adjustable error rate, logging db is working, etc'); + //MAX_CONTENT_LENGTH_BYTES REQUESTS_PER_CONTRIVED_ERROR DISALLOW_WRITES LOCKOUT_ALL_KEYS DISABLE_RATE_LIMITS + }); +}); diff --git a/src/pages/api/v1/election.ts b/src/pages/api/v1/election.ts new file mode 100644 index 0000000..590b0d0 --- /dev/null +++ b/src/pages/api/v1/election.ts @@ -0,0 +1,8 @@ + + +import type { NextApiRequest, NextApiResponse } from 'next' + +export default async function(req: NextApiRequest, res: NextApiResponse): Promise { + void req; + void res; +} diff --git a/src/pages/api/v1/elections.ts b/src/pages/api/v1/elections.ts new file mode 100644 index 0000000..5da7d7c --- /dev/null +++ b/src/pages/api/v1/elections.ts @@ -0,0 +1,8 @@ + + +import type { NextApiResponse, NextApiRequest } from 'next' + +export default async function(req: NextApiRequest, res: NextApiResponse): Promise { + void req; + void res; +} diff --git a/src/pages/api/v1/index.ts b/src/pages/api/v1/index.ts deleted file mode 100644 index 661dab6..0000000 --- a/src/pages/api/v1/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MongoClient } from 'mongodb' - -import type { NextApiResponse } from 'next' -import type { NextSessionRequest } from 'multiverse/simple-auth-session' - -export default async function(req: NextSessionRequest, res: NextApiResponse): Promise { - void req; - const client = await MongoClient.connect(process.env.MONGODB_URI || '', { useUnifiedTopology: true }); - const db = client.db(); - const meta = db.collection('meta'); - - res.status(200).send(await meta.find({}, { projection: { _id: false }}).next()); -} diff --git a/src/pages/api/v1/meta.ts b/src/pages/api/v1/meta.ts new file mode 100644 index 0000000..7cf2491 --- /dev/null +++ b/src/pages/api/v1/meta.ts @@ -0,0 +1,8 @@ +import { getElectionMetadata } from 'universe/backend' +import { sendHttpOk } from 'multiverse/respond' + +import type { NextApiResponse, NextApiRequest } from 'next' + +export default async function(_: NextApiRequest, res: NextApiResponse): Promise { + sendHttpOk(res, await getElectionMetadata()); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 6a0a4a1..2201829 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,5 +1,13 @@ import * as React from 'react' +import { getEnv } from 'universe/backend/env' + +const env = getEnv().NODE_ENV; export default function Index(): JSX.Element { - return (

Psst: there is no web frontend for this API.

); + return ( + +

Psst: there is no web frontend for this API.

+ { env != 'production' &&

{`(in ${env} mode!)`}

} +
+ ); } diff --git a/types/_shared.ts b/types/_shared.ts new file mode 100644 index 0000000..8605994 --- /dev/null +++ b/types/_shared.ts @@ -0,0 +1,45 @@ +// * These are global types shared and reused freely between many projects +// TODO: make this into @ergodark/types private? namespaced package + +import type { NextApiRequest, NextApiResponse } from 'next' + +export type GenericObject = Record; + +export type SuccessJsonResponse = { success: true } +export type ErrorJsonResponse = { error: string }; +export type HttpJsonResponse2xx = SuccessJsonResponse; +export type HttpJsonResponse3xx = SuccessJsonResponse; +export type HttpJsonResponse4xx = ErrorJsonResponse; +export type HttpJsonResponse5xx = ErrorJsonResponse; + +export type HttpJsonResponse429 = HttpJsonResponse4xx & { retryAfter: number }; + +export type HttpStatusCode = + 100 | 101 | 102 + + | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 + + | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 + | 419 | 420 | 420 | 422 | 423 | 424 | 424 | 425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 | 451 | 494 | 495 + | 496 | 497 | 499 + + | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 555 | 598 | 599; + +export type NextParamsResponseQuery = { + res: NextApiResponse; + query: object; +}; + +export type NextParamsResponseStatus = { + res: NextApiResponse; + status: HttpStatusCode; +}; + +export type NextParamsRR = { + req: NextApiRequest; + res: NextApiResponse; +}; + +export type NextParamsRRQ = NextParamsRR & { query: object }; +export type NextParamsResponseStatusQuery = NextParamsResponseQuery & NextParamsResponseStatus; diff --git a/types/global.ts b/types/global.ts index 8dd241d..5235589 100644 --- a/types/global.ts +++ b/types/global.ts @@ -1,38 +1,10 @@ -import type { NextApiRequest, NextApiResponse } from 'next' -import type { NextParamsRRWithSession } from 'multiverse/simple-auth-session' +import type { HttpJsonResponse5xx } from './_shared' import type { ObjectId } from 'mongodb' -export type GenericObject = Record; +// ? Access types shared between projects from `types/global` too +export * from './_shared'; -export type HTTPStatusCode = - 100 | 101 | 102 - - | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 - | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 - - | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 - | 419 | 420 | 420 | 422 | 423 | 424 | 424 | 425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 | 451 | 494 | 495 - | 496 | 497 | 499 - - | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 598 | 599; - -export type NextParamsResponseQuery = { - res: NextApiResponse; - query: object; -}; - -export type NextParamsResponseStatus = { - res: NextApiResponse; - status: HTTPStatusCode; -}; - -export type NextParamsRR = { - req: NextApiRequest; - res: NextApiResponse; -}; - -export type NextParamsRRQWithSession = NextParamsRRWithSession & { query: object }; -export type NextParamsResponseStatusQuery = NextParamsResponseQuery & NextParamsResponseStatus; +// * Project-specific Types * \\ export type Option = string; @@ -46,16 +18,39 @@ export type PrimitiveElection = { deleted: boolean; }; +export type NewElection = { + title: string; + description?: string; + options?: Option[]; + opens: number; + closes: number; +}; + +export type PatchElection = { + _id: ObjectId; + title?: string; + description?: string; + options?: Option[]; + opens?: number; + closes?: number; + deleted?: boolean; +}; + export type InternalElection = PrimitiveElection & { _id: ObjectId; owner: string; }; -export type Election = PrimitiveElection & { +export type PublicElection = PrimitiveElection & { election_id: ObjectId; owned: boolean; }; +export type ApiKey = { + owner: string; + key: string; +} + export type Ranking = { voter_id: string; ranking: Option[]; @@ -68,10 +63,27 @@ export type ElectionRankings = { rankings: Rankings; }; -export type ErrorJSON = { - error: string; +export type RequestLogEntry = { + _id: ObjectId; + ip: string; + key: string | null; + route: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + time: number; + response: number; +}; + +export type LimitedEntry = { + _id: ObjectId; + until: number; + ip?: string; + key?: string; +}; + +export type Metadata = { + upcomingElections: number; + openElections: number; + closedElections: number; }; -export type SuccessJSON = { success: true }; -export type HTTP555 = ErrorJSON & { contrived: true }; -export type HTTP429 = ErrorJSON & { retryAfter: number }; +export type HTTP555 = HttpJsonResponse5xx & { contrived: true }; diff --git a/types/next-session.d.ts b/types/next-session.d.ts deleted file mode 100644 index 72fc7b1..0000000 --- a/types/next-session.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'next-session';