diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..ab4fde0d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Preface + +There are a _lot_ of moving parts that go into the Halo Notification Service tool suite and this repository especially. The codebase has **not** been optimized for third-party contributors, so there will be a degree of difficulty when setting up the initial project. Those unfamiliar with programming in Node.js environments will find navigating the codebase to be exceptionally difficult. + +# Setting Up + +This repository is, at its core, a Discord bot running in a [Node.js](https://nodejs.org/) environment. To begin setting up the project, clone the repository and navigate to the local directory. At the time of writing, the latest version of Node 16 is being used. + +[pnpm](https://pnpm.io/) is the package manager for this project. Check out the [official installation guide](https://pnpm.io/) if you do not have that set up. + +In the project directory, install the npm packages by running + +```cmd +pnpm i --frozen-lockfile +``` + +Next, let's set up the environment variables. + +# Environment Variables + +`./env.sample` contains a template env var file with sample variables to fill in. Copy this file and rename the copy to `.env`. We'll go in order of the env variables: + +## `BOT_TOKEN` + +This is the Discord bot client secret that can be used to programmatically interact with the Discord API and autonomously control a bot user. [Discord.js](https://discord.js.org/#/) has published a brief [guide](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot) that explains how to create a bot account and obtain its token. + +## `GOOGLE_APPLICATION_CREDENTIALS` + +[Google Firebase](https://firebase.google.com/) is the cloud database provider for this project. Unfortunately, due to the size of this project, there is no official mock database seed that can be loaded into development environments. However, for testing purposes, a Firebase instance can still be created and the credentials downloaded locally. This [guide](https://cloud.google.com/firestore/docs/client/get-firebase) explains how to create a new Firebase project. + +## `RSA_PUBLIC_KEY` & `RSA_PRIVATE_KEY` + +For [FERPA](https://studentprivacy.ed.gov/ferpa#0.1_se34.1.99_11)-compliancy, all student education records are encrypted with a hybrid AES/RSA encryption algorithm. The RSA public/private keys are uniquely generated per environment, and should not be shared or reused after their initial generation. A simple Node.js script can generate these keys, base-64 encode them, and write them to the local filesystem: + +```js +import { generateKeyPairSync } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 3072 }); +await writeFile('public.pem', Buffer.from(publicKey.export({ type: 'spki', format: 'pem' })).toString('base64')); +await writeFile('private.pem', Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toString('base64')); +``` + +The content of these files can be directly used as values for the `RSA_PUBLIC_KEY` and `RSA_PRIVATE_KEY` environment variables. + +# Local Development + +For propriety, a (skeleton) mock API exists to test interactions between the Node.js app and the Halo API in an isolated environment. Unfortunately, the mock data for this mock API cannot be provided to contributors for security reasons. Examination of the `api/` directory will reveal a simple [Express.js](https://expressjs.com/) app. Close inspection of the `GatewayController` should reveal the different types of data that need to be included in the mock API, as well as where to store them. The bot will compile and initialize indepently of the mock API. + +To start the Discord bot in a development environment, run the command: + +```cmd +pnpm start +``` + +The entry point of the app is `index.js`. + +# Conclusion + +Apologies for the hastily-assembled contributing guide. I am a strong supporter of OSS, but I am also a student, and my time can only be split between so many things. Because the number of people that would benefit from the features of this project vastly outnumbers the number of people that would benefit from the documentation of it, I have accordingly prioritized feature-driven development. diff --git a/classes/CookieManager.js b/classes/CookieManager.js index b71e86da..4dadfa07 100644 --- a/classes/CookieManager.js +++ b/classes/CookieManager.js @@ -133,10 +133,12 @@ export class CookieManager { const ref = db.ref('cookies'); ref.orderByChild('timestamp').startAt(Date.now()).on('child_added', updateHandler); ref.on('child_changed', updateHandler); - //cookie was deleted, check to see it if was an uninstall + //cookie was deleted, make sure it was due to an uninstall + //don't want to be deleting server-side cookies accidentally ref.on('child_removed', async (snapshot) => { const uid = snapshot.key; - if (!(await Firebase.getFirebaseUserSnapshot(uid))?.uninstalled) return; + //ext_devices should only be zero when the user has completely uninstalled + if ((await Firebase.getFirebaseUserSnapshot(uid))?.ext_devices !== 0) return; Logger.cookie(`${uid}'s cookie has been removed`); clearTimeout(timeouts.get(uid)); //clear timeout if it already exists for this user diff --git a/classes/services/401Service.js b/classes/services/401Service.js index df4bced0..870f2d7b 100644 --- a/classes/services/401Service.js +++ b/classes/services/401Service.js @@ -33,7 +33,7 @@ export const handle401 = async function ({ uid, msg }) { //check if setting enabled if (!Firebase.getUserSettingValue({ uid, setting_id: 3 })) return; // send notification to user - const user = await bot.users.fetch(Firebase.getDiscordUid(uid)); + const user = await bot.users.fetch(uid); const msg = await bot.sendDM({ user, embed: new EmbedBase({ diff --git a/classes/services/AnnouncementService.js b/classes/services/AnnouncementService.js index 8f2aa1d9..bf48013a 100644 --- a/classes/services/AnnouncementService.js +++ b/classes/services/AnnouncementService.js @@ -39,14 +39,11 @@ export class AnnouncementService { for (const uid of Firebase.getActiveUsersInClass(announcement.courseClassId)) { try { // if (!Firebase.getUserSettingValue({ uid, setting_id: 0 })) continue; - const discord_uid = Firebase.getDiscordUid(uid); - const discord_user = await bot.users.fetch(discord_uid); + const discord_user = await bot.users.fetch(uid); discord_user .send(message) - .catch((e) => - Logger.error(`Error sending announcement to ${discord_user.tag} (${discord_uid}): ${e}`) - ); - Logger.log(`Announcement DM sent to ${discord_user.tag} (${discord_uid})`); + .catch((e) => Logger.error(`Error sending announcement to ${discord_user.tag} (${uid}): ${e}`)); + Logger.log(`Announcement DM sent to ${discord_user.tag} (${uid})`); bot.logDiscord({ embed: new EmbedBase({ title: 'Announcement Message Sent', diff --git a/classes/services/FirebaseService.js b/classes/services/FirebaseService.js index f1ed5a84..a595b0ce 100644 --- a/classes/services/FirebaseService.js +++ b/classes/services/FirebaseService.js @@ -18,7 +18,7 @@ import { ServerValue } from 'firebase-admin/database'; import { decryptCookieObject, encryptCookieObject, isValidCookieObject, Logger } from '..'; import { COOKIES } from '../../caches'; import { db } from '../../firebase'; -import { CLASS_USERS_MAP, DEFAULT_SETTINGS_STORE, DISCORD_USER_MAP, USER_SETTINGS_STORE } from '../../stores'; +import { CLASS_USERS_MAP, DEFAULT_SETTINGS_STORE, USER_SETTINGS_STORE } from '../../stores'; const ACTIVE_STAGES = ['PRE_START', 'CURRENT', 'POST']; export const getActiveClasses = async function () { @@ -32,30 +32,14 @@ export const getAllClasses = async function () { }; /** - * @returns {string[]} array of discord uids - */ -export const getActiveDiscordUsersInClass = function (class_id) { - return process.env.NODE_ENV === 'production' - ? Object.keys(CLASS_USERS_MAP.get(class_id) ?? {}).map(DISCORD_USER_MAP.get) - : ['139120967208271872']; -}; - -/** - * @returns {Promise} array of HNS IDs - */ -export const getActiveUsersInClassAsync = async function (class_id) { - return Object.keys((await db.ref('class_users_map').child(class_id).get()) ?? {}); -}; - -/** - * @returns {string[]} array of HNS IDs + * @returns {string[]} array of Discord uids */ export const getActiveUsersInClass = function (class_id) { return Object.keys(CLASS_USERS_MAP.get(class_id) ?? {}); }; /** - * @param {string} uid HNS UID + * @param {string} uid discord UID * @returns {Promise} array of class IDs */ export const getAllUserClasses = async function (uid) { @@ -64,7 +48,7 @@ export const getAllUserClasses = async function (uid) { /** * Get the Halo cookie object for a user - * @param {string} uid HNS UID + * @param {string} uid Discord UID * @param {boolean} check_cache Whether the local cache should be checked first */ export const getUserCookie = async function (uid, check_cache = true) { @@ -95,6 +79,7 @@ export const removeUserCookie = async function (uid) { /** * Convert a Halo UID to a Discord UID + * TODO: implement caching to improve performance * @param {string} uid halo user id * @returns {Promise} discord user id */ @@ -104,34 +89,16 @@ export const getDiscordUidFromHaloUid = async function (uid) { : '139120967208271872'; }; -/** - * Convert a HNS UID to a Discord UID - * @param {string} uid - * @returns {string | null} discord uid, if exists in map - */ -export const getDiscordUid = function (uid) { - return process.env.NODE_ENV === 'production' ? DISCORD_USER_MAP.get(uid) : '139120967208271872'; -}; - -/** - * Convert a Discord UID to a HNS UID - * @param {string} discord_uid - * @returns {string | null} HNS uid, if exists in map - */ -export const getHNSUid = function (discord_uid) { - return DISCORD_USER_MAP.get(discord_uid); -}; - export const getFirebaseUserSnapshot = async function (uid) { return (await db.ref('users').child(uid).once('value')).val(); }; /** * Get all users currently using the service - * @returns {Promise} array of HNS uids + * @returns {Promise} array of Discord uids */ export const getAllActiveUsers = async function getAllActiveUsersUids() { - return Object.keys((await db.ref('users').orderByChild('uninstalled').equalTo(null).get()).val() ?? {}); + return Object.keys((await db.ref('users').orderByChild('ext_devices').startAt(1).get()).val() ?? {}); }; /** @@ -139,12 +106,12 @@ export const getAllActiveUsers = async function getAllActiveUsersUids() { * @returns {Promise} */ export const getAllActiveUsersFull = async function () { - return (await db.ref('users').orderByChild('uninstalled').equalTo(null).get()).val() ?? {}; + return (await db.ref('users').orderByChild('ext_devices').startAt(1).get()).val() ?? {}; }; /** * Retrieve a user's settings - * @param {string} uid discord-halo uid + * @param {string} uid discord uid * @returns {object} user settings */ export const getUserSettings = function (uid) { @@ -154,7 +121,7 @@ export const getUserSettings = function (uid) { /** * Get the user-set value associated with the `setting_id` * @param {object} args Destructured arguments - * @param {string} args.uid discord-halo uid + * @param {string} args.uid discord uid * @param {string | number} args.setting_id ID of setting to retieve * @returns {any} The value of the user's setting if set, otherwise the default setting value */ diff --git a/classes/services/GradeService.js b/classes/services/GradeService.js index 24038c58..7b64f9d8 100644 --- a/classes/services/GradeService.js +++ b/classes/services/GradeService.js @@ -33,11 +33,9 @@ export class GradeService { */ static async #publishGrade({ grade }) { try { - const discord_uid = - Firebase.getDiscordUid(grade?.metadata?.uid) ?? - (await Firebase.getDiscordUidFromHaloUid(grade.user.id)); + const discord_uid = grade?.metadata?.uid ?? (await Firebase.getDiscordUidFromHaloUid(grade.user.id)); const show_overall_grade = Firebase.getUserSettingValue({ - uid: Firebase.getHNSUid(discord_uid), + uid: discord_uid, setting_id: 4, }); diff --git a/classes/services/HaloService.js b/classes/services/HaloService.js index 3220cc63..3ec7ae36 100644 --- a/classes/services/HaloService.js +++ b/classes/services/HaloService.js @@ -230,14 +230,13 @@ export const getUserId = async function ({ cookie }) { * @param {string} args.uid Discord UID of the user * @returns {Promise} */ -export const generateUserConnectionEmbed = async function ({ uid: discord_uid }) { +export const generateUserConnectionEmbed = async function ({ uid }) { try { - const uid = Firebase.getHNSUid(discord_uid); const cookie = await Firebase.getUserCookie(uid); if (!(await validateCookie({ cookie }))) throw `Cookie for ${uid} failed to pass validation`; return new EmbedBase({ - description: '✅ **Your account is currently connceted to Halo**', + description: '✅ **Your account is currently connected to Halo**', }).Success(); } catch (err) { return new EmbedBase().ErrorDesc('Your account is currently not connected to Halo'); diff --git a/classes/services/InboxMessageService.js b/classes/services/InboxMessageService.js index 62968d82..dfeaea11 100644 --- a/classes/services/InboxMessageService.js +++ b/classes/services/InboxMessageService.js @@ -37,8 +37,7 @@ export class InboxMessageService { static async #publishInboxMessage({ inbox_message, message }) { try { const discord_uid = - Firebase.getDiscordUid(inbox_message?.metadata?.uid) ?? - (await Firebase.getDiscordUidFromHaloUid(inbox_message.user.id)); + inbox_message?.metadata?.uid ?? (await Firebase.getDiscordUidFromHaloUid(inbox_message.user.id)); const discord_user = await bot.users.fetch(discord_uid); discord_user .send(message) diff --git a/commands/development/test.js b/commands/development/test.js index eccce3d0..4feb1789 100644 --- a/commands/development/test.js +++ b/commands/development/test.js @@ -28,8 +28,7 @@ class test extends Command { async run({ intr }) { try { - const uid = Firebase.getHNSUid(intr.user.id); - const cookie = await Firebase.getUserCookie(uid); + const cookie = await Firebase.getUserCookie(intr.user.id); // const feedback = await Halo.getGradeFeedback({ // cookie, diff --git a/events/discord/interactionCreate/acknowledgeAnnouncement.js b/events/discord/interactionCreate/acknowledgeAnnouncement.js index 9a379bd8..9cfc2f9b 100644 --- a/events/discord/interactionCreate/acknowledgeAnnouncement.js +++ b/events/discord/interactionCreate/acknowledgeAnnouncement.js @@ -40,24 +40,20 @@ export default class extends DiscordEvent { try { Logger.cmd(`${intr.user.tag} (${intr.user.id}) clicked ${this.name} btn with id of ${intr.customId}`); await Halo.acknowledgePost({ - cookie: await Firebase.getUserCookie(Firebase.getHNSUid(intr.user.id)), + cookie: await Firebase.getUserCookie(intr.user.id), post_id, }); - // copy components, disable button, then update + // copy components, disable button, then update as "confirmation" to user const components = intr.message.components; - components[0].components.find(({ customId }) => customId === intr.customId).disabled = true; + Object.assign( + components + .flatMap(({ components }) => components) + // api is weird and return customId if cached, custom_id otherwise + .find(({ custom_id, customId }) => (custom_id ?? customId) === intr.customId), + { disabled: true, style: 'SUCCESS', label: 'Marked as Read', emoji: { name: '✅' } } + ); await intr.update({ components }); - - // send response to user - bot.intrReply({ - intr, - ephemeral: true, - followUp: true, - embed: new EmbedBase({ - description: `✅ **Announcement successfully marked as read**`, - }).Success(), - }); } catch (err) { Logger.error(`Error with btn ${this.name} ${intr.customId}: ${err}`); bot.intrReply({ diff --git a/events/discord/interactionCreate/acknowledgeGrade.js b/events/discord/interactionCreate/acknowledgeGrade.js index 6225638e..2598d7d7 100644 --- a/events/discord/interactionCreate/acknowledgeGrade.js +++ b/events/discord/interactionCreate/acknowledgeGrade.js @@ -40,24 +40,20 @@ export default class extends DiscordEvent { try { Logger.cmd(`${intr.user.tag} (${intr.user.id}) clicked ${this.name} btn with id of ${intr.customId}`); await Halo.acknowledgeGrade({ - cookie: await Firebase.getUserCookie(Firebase.getHNSUid(intr.user.id)), + cookie: await Firebase.getUserCookie(intr.user.id), assessment_grade_id, }); - // copy components, disable button, then update + // copy components, disable button, then update as "confirmation" to user const components = intr.message.components; - components[0].components.find(({ customId }) => customId === intr.customId).disabled = true; + Object.assign( + components + .flatMap(({ components }) => components) + // api is weird and return customId if cached, custom_id otherwise + .find(({ custom_id, customId }) => (custom_id ?? customId) === intr.customId), + { disabled: true, style: 'SUCCESS', label: 'Marked as Read', emoji: { name: '✅' } } + ); await intr.update({ components }); - - // send response to user - bot.intrReply({ - intr, - ephemeral: true, - followUp: true, - embed: new EmbedBase({ - description: `✅ **Grade successfully marked as read**`, - }).Success(), - }); } catch (err) { Logger.error(`Error with btn ${this.name} ${intr.customId}: ${err}`); bot.intrReply({ diff --git a/events/firebase/userCreate.js b/events/firebase/userCreate.js index 6ccb1ed3..e34524ea 100644 --- a/events/firebase/userCreate.js +++ b/events/firebase/userCreate.js @@ -33,13 +33,8 @@ class UserCreate extends FirebaseEvent { * @param {DataSnapshot} snapshot */ async onAdd(snapshot) { - const { discord_uid } = snapshot.val(); const uid = snapshot.key; - Logger.debug(`New user created: ${JSON.stringify(snapshot.val())}`); - //set custom claim - await auth.setCustomUserClaims(uid, { discordUID: discord_uid }); - //update mapping table - await db.ref('discord_user_map').child(discord_uid).set(uid); + Logger.debug(`New user created: ${uid}: ${JSON.stringify(snapshot.val())}`); //retrieve and set their halo id (at this point, user should have halo cookie in db) const cookie = await Firebase.getUserCookie(uid, false); //await CookieManager.refreshUserCookie(uid, cookie); //immediately refresh cookie to trigger cache intervals @@ -47,7 +42,7 @@ class UserCreate extends FirebaseEvent { await db.ref(`users/${uid}`).child('halo_id').set(halo_id); //(attempt to) send connection message to user - const user = await bot.users.fetch(discord_uid); + const user = await bot.users.fetch(uid); await bot.sendDM({ user, send_disabled_msg: false, @@ -122,12 +117,23 @@ class UserCreate extends FirebaseEvent { * @param {DataSnapshot} snapshot */ async onModify(snapshot) { - Logger.debug(`doc ${snapshot.key} modified`); - Logger.debug(snapshot.val()); + const uid = snapshot.key; + const data = snapshot.val(); + Logger.debug(`doc ${uid} modified: ${JSON.stringify(data)}`); + + //at the moment, the only way to determine a reinstall is for these two conditions to be met: + //1. ext_devices has been modified and set to 1 + //2. the `uninstalled` timestamp is present but the date is significantly in the past + + const uninstall_timestamp = data?.uninstalled; + + if (!Number.isInteger(uninstall_timestamp)) return; + //extension uninstall process - if (!!snapshot.val()?.uninstalled) { - const { discord_uid, halo_id } = snapshot.val(); - const uid = snapshot.key; + //if uninstall did not occur within the last 5 seconds, ignore + //this is to allow other modifications to the user doc to occur without triggering the uninstall process + if (Date.now() - uninstall_timestamp <= 5000) { + const { halo_id, ext_devices } = data; Logger.uninstall(uid); @@ -139,8 +145,32 @@ class UserCreate extends FirebaseEvent { }).catch(() => null)) ?? {}; userInfo ??= {}; - //delete user from discord_user_map - await db.ref('discord_user_map').child(discord_uid).remove(); + //send message to bot channel + bot.logConnection({ + embed: new EmbedBase({ + title: 'User Uninstalled', + fields: [ + { + name: 'Discord User', + value: bot.formatUser(await bot.users.fetch(uid)), + inline: true, + }, + { + name: 'Device Count', + value: ext_devices.toString(), + inline: true, + }, + { + name: 'Halo User', + value: !Object.keys(userInfo).length + ? 'Unable to retrieve user info' + : `${userInfo.firstName} ${userInfo.lastName} (\`${halo_id}\`)`, + }, + ], + }).Error(), + }).catch(() => {}); //noop + + if (ext_devices > 0) return; //don't delete user data if they have the ext installed on other devices //remove their discord tokens await db.ref(`discord_tokens/${uid}`).remove(); @@ -154,7 +184,10 @@ class UserCreate extends FirebaseEvent { //remove their cookies await Firebase.removeUserCookie(uid); - //handle further cookie removal in CookieWatcher + //this triggers further cookie removal in CookieManager + + //remove their user doc + await db.ref('users').child(uid).remove(); //delete their user acct in case they reinstall, to retrigger the auth process await auth.deleteUser(uid); @@ -163,27 +196,19 @@ class UserCreate extends FirebaseEvent { CRON_USER_CLASS_STATUSES.delete(uid); //remove user from 401 cache - remove401(uid); - - //send message to bot channel - bot.logConnection({ - embed: new EmbedBase({ - title: 'User Uninstalled', - fields: [ - { - name: 'Discord User', - value: bot.formatUser(await bot.users.fetch(discord_uid)), - }, - { - name: 'Halo User', - value: !Object.keys(userInfo).length - ? 'Unable to retrieve user info' - : `${userInfo.firstName} ${userInfo.lastName} (\`${halo_id}\`)`, - }, - ], - }).Error(), - }); + void remove401(uid); } + + //some more care needs to be given to the reinstall flow + //can we just delete the user doc entirely? pros/cons + //below conditional (may) get triggered in initial install flow bc of the updating of halo_id + + //if uninstall occurred "significantly" in the past + //AND the ext_devices is 1, then the user has reinstalled the ext + // else if (Date.now() - uninstall_timestamp >= 10000 && data.ext_devices === 1) { + // //trigger initial install flow + // this.onAdd(snapshot, true); + // } } onRemove(snapshot) { diff --git a/package.json b/package.json index 359f931a..2c28e8f2 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "halo-discord-bot", - "version": "2.4.1", + "version": "2.5.0", "author": "Elijah Olmos", "license": "AGPL-3.0-only", "main": "index.js", "type": "module", "private": true, "scripts": { - "test": "nodemon --ignore logs/ --ignore cache/ --ignore api/", - "test:api": "nodemon -w api/ api/index.js", + "dev": "nodemon --ignore logs/ --ignore cache/ --ignore api/", + "dev:api": "nodemon -w api/ api/index.js", "start": "node --experimental-specifier-resolution=node .", "deploy": "pm2 start index.js --name halo-discord --node-args='--experimental-specifier-resolution=node'" }, @@ -31,7 +31,7 @@ "discord.js": "13.9.2", "dotenv": "16.0.3", "firebase-admin": "11.4.1", - "klaw": "4.0.1", + "klaw": "4.1.0", "lodash-es": "4.17.21", "moment": "2.29.4", "node-schedule": "2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 138ef0a1..abefea0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ specifiers: dotenv: 16.0.3 express: 4.18.2 firebase-admin: 11.4.1 - klaw: 4.0.1 + klaw: 4.1.0 lodash-es: 4.17.21 moment: 2.29.4 node-schedule: 2.1.0 @@ -21,7 +21,7 @@ dependencies: discord.js: 13.9.2 dotenv: 16.0.3 firebase-admin: 11.4.1_@firebase+app-types@0.9.0 - klaw: 4.0.1 + klaw: 4.1.0 lodash-es: 4.17.21 moment: 2.29.4 node-schedule: 2.1.0 @@ -1407,8 +1407,8 @@ packages: dev: false optional: true - /klaw/4.0.1: - resolution: {integrity: sha512-pgsE40/SvC7st04AHiISNewaIMUbY5V/K8b21ekiPiFoYs/EYSdsGa+FJArB1d441uq4Q8zZyIxvAzkGNlBdRw==} + /klaw/4.1.0: + resolution: {integrity: sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==} engines: {node: '>=14.14.0'} dev: false diff --git a/stores.js b/stores.js index 74c4b3a6..13fcca98 100644 --- a/stores.js +++ b/stores.js @@ -16,7 +16,6 @@ import { FirebaseStore } from './classes'; -export const DISCORD_USER_MAP = new FirebaseStore({ path: 'discord_user_map', bimap: true }); export const USER_CLASSES_MAP = new FirebaseStore({ path: 'user_classes_map' }); export const CLASS_USERS_MAP = new FirebaseStore({ path: 'class_users_map' }); export const USER_SETTINGS_STORE = new FirebaseStore({ path: 'user_settings' });