├── .eslintrc.json ├── .github ├── FUNDING.yml ├── release.yml └── workflows │ ├── ci.yml │ ├── create-release.yml │ ├── docker.yml │ └── tag.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ └── bullmq.ts ├── _templates ├── generator │ ├── help │ │ └── index.ejs.t │ └── new │ │ └── hello.ejs.t └── service │ └── new │ ├── api-key-scope.ejs.t │ ├── api-policy.ejs.t │ ├── api-route-import.ejs.t │ ├── api-route.ejs.t │ ├── api-service-test.ejs.t │ ├── api-service.ejs.t │ ├── entities-import.ejs.t │ ├── entities.ejs.t │ ├── entity.ejs.t │ ├── factory.ejs.t │ ├── policy.ejs.t │ ├── protected-route-import.ejs.t │ ├── protected-route.ejs.t │ ├── service-test.ejs.t │ └── service.ejs.t ├── codecov.yml ├── docker-compose.ci.yml ├── docker-compose.test.yml ├── docker-compose.yml ├── envs ├── .dockerignore ├── .env.dev └── .env.test ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── config │ ├── api-routes.ts │ ├── mikro-orm.config.ts │ ├── protected-routes.ts │ ├── providers.ts │ ├── public-routes.ts │ ├── redis.config.ts │ └── scheduled-tasks.ts ├── docs │ ├── api-docs.ts │ ├── event-api.docs.ts │ ├── game-channel-api.docs.ts │ ├── game-config-api.docs.ts │ ├── game-feedback-api.docs.ts │ ├── game-save-api.docs.ts │ ├── game-stat-api.docs.ts │ ├── leaderboard-api.docs.ts │ ├── player-api.docs.ts │ ├── player-auth-api.docs.ts │ ├── player-group-api.docs.ts │ ├── player-presence-api.docs.ts │ └── socket-tickets-api.docs.ts ├── emails │ ├── confirm-email-mail.ts │ ├── data-export-ready-mail.ts │ ├── email-template.ts │ ├── join-organisation-mail.ts │ ├── mail.ts │ ├── plan-cancelled-mail.ts │ ├── plan-invoice-mail.ts │ ├── plan-payment-failed.ts │ ├── plan-renewed-mail.ts │ ├── plan-upgraded-mail.ts │ ├── plan-usage-warning-mail.ts │ ├── player-auth-code-mail.ts │ ├── player-auth-reset-password-mail.ts │ └── reset-password.ts ├── entities │ ├── api-key.ts │ ├── data-export.ts │ ├── event.ts │ ├── failed-job.ts │ ├── game-activity.ts │ ├── game-channel-prop.ts │ ├── game-channel-storage-prop.ts │ ├── game-channel.ts │ ├── game-feedback-category.ts │ ├── game-feedback.ts │ ├── game-save.ts │ ├── game-secret.ts │ ├── game-stat.ts │ ├── game.ts │ ├── index.ts │ ├── integration.ts │ ├── invite.ts │ ├── leaderboard-entry-prop.ts │ ├── leaderboard-entry.ts │ ├── leaderboard.ts │ ├── organisation-pricing-plan.ts │ ├── organisation.ts │ ├── player-alias.ts │ ├── player-auth-activity.ts │ ├── player-auth.ts │ ├── player-game-stat-snapshot.ts │ ├── player-game-stat.ts │ ├── player-group-rule.ts │ ├── player-group.ts │ ├── player-presence.ts │ ├── player-prop.ts │ ├── player-session.ts │ ├── player.ts │ ├── pricing-plan.ts │ ├── prop.ts │ ├── steamworks-integration-event.ts │ ├── steamworks-leaderboard-mapping.ts │ ├── subscribers │ │ ├── index.ts │ │ └── player-group.subscriber.ts │ ├── user-access-code.ts │ ├── user-pinned-group.ts │ ├── user-recovery-code.ts │ ├── user-session.ts │ ├── user-two-factor-auth.ts │ └── user.ts ├── global.d.ts ├── index.ts ├── lib │ ├── auth │ │ ├── buildTokenPair.ts │ │ ├── generateRecoveryCodes.ts │ │ ├── generateSixDigitCode.ts │ │ ├── getAPIKeyFromToken.ts │ │ ├── getUserFromToken.ts │ │ └── jwt.ts │ ├── billing │ │ ├── checkPricingPlanPlayerLimit.ts │ │ ├── createDefaultPricingPlan.ts │ │ ├── getBillablePlayerCount.ts │ │ └── initStripe.ts │ ├── clickhouse │ │ ├── clickhouse-entity.ts │ │ ├── createClient.ts │ │ └── formatDateTime.ts │ ├── crypto │ │ └── string-encryption.ts │ ├── dates │ │ ├── dateValidationSchema.ts │ │ └── randomDate.ts │ ├── demo-data │ │ └── generateDemoEvents.ts │ ├── entities │ │ └── updateAllowedKeys.ts │ ├── errors │ │ ├── buildErrorResponse.ts │ │ ├── checkRateLimitExceeded.ts │ │ ├── handleSQLError.ts │ │ └── propSizeError.ts │ ├── groups │ │ ├── checkGroupMemberships.ts │ │ └── rulesValidation.ts │ ├── integrations │ │ ├── steamworks-integration.ts │ │ └── triggerIntegrations.ts │ ├── lang │ │ ├── emailRegex.ts │ │ └── upperFirst.ts │ ├── logging │ │ ├── createGameActivity.ts │ │ └── createPlayerAuthActivity.ts │ ├── messaging │ │ ├── queueEmail.ts │ │ └── sendEmail.ts │ ├── props │ │ └── sanitiseProps.ts │ ├── queues │ │ ├── createEmailQueue.ts │ │ ├── createQueue.ts │ │ └── handleJobFailure.ts │ └── users │ │ └── setUserLastSeenAt.ts ├── middlewares │ ├── api-key-middleware.ts │ ├── cleanup-middleware.ts │ ├── continunity-middleware.ts │ ├── cors-middleware.ts │ ├── current-player-middleware.ts │ ├── dev-data-middleware.ts │ ├── error-middleware.ts │ ├── limiter-middleware.ts │ ├── player-auth-middleware.ts │ ├── request-context-middleware.ts │ ├── route-middleware.ts │ └── tracing-middleware.ts ├── migrations │ ├── .snapshot-gs_dev.json │ ├── 20210725211129InitialMigration.ts │ ├── 20210926160859CreateDataExportsTable.ts │ ├── 20211107233610CreateLeaderboardsTable.ts │ ├── 20211205171927CreateUserTwoFactorAuthTable.ts │ ├── 20211209003017CreateUserRecoveryCodeTable.ts │ ├── 20211224154919AddLeaderboardEntryHiddenColumn.ts │ ├── 20220109144435CreateGameSavesTable.ts │ ├── 20220125220401CreateGameActivitiesTable.ts │ ├── 20220203130919SetUserTwoFactorAuthEnabledDefaultFalse.ts │ ├── 20220320171104CreateGameStatsTable.ts │ ├── 20220402004932AddUsernameColumn.ts │ ├── 20220420141136CreateInvitesTable.ts │ ├── 20220505190243MakeGameActivityUserNullable.ts │ ├── 20220603123117CreatePricingPlansTable.ts │ ├── 20220717215205CreateIntegrationsTable.ts │ ├── 20220723122554CreateSteamIntegrationTables.ts │ ├── 20220730134520PlayerAliasServiceUseEnum.ts │ ├── 20220910200720CreatePlayerPropsTable.ts │ ├── 20220914003848CreatePlayerGroupsTables.ts │ ├── 20221113222058AddFailedJobStackColumn.ts │ ├── 20221113223142DropSteamworksLeaderboardMappingUnique.ts │ ├── 20230205220923UpdateTableDefaultValues.ts │ ├── 20230205220924CreateGameSecretsTable.ts │ ├── 20230205220925AddAPIKeyLastUsedAtColumn.ts │ ├── 20240606165637CreateGameFeedbackAndCategoryTables.ts │ ├── 20240614122547AddAPIKeyUpdatedAtColumn.ts │ ├── 20240628155142CreatePlayerAuthTable.ts │ ├── 20240725183402CreatePlayerAuthActivityTable.ts │ ├── 20240916213402UpdatePlayerAliasServiceColumn.ts │ ├── 20240920121232AddPlayerAliasAnonymisedColumn.ts │ ├── 20240922222426AddLeaderboardEntryPropsColumn.ts │ ├── 20241001194252CreateUserPinnedGroupsTable.ts │ ├── 20241014202844AddPlayerGroupMembersVisibleColumn.ts │ ├── 20241101233908AddPlayerPropCreatedAtColumn.ts │ ├── 20241102004938AddPlayerAliasLastSeenAtColumn.ts │ ├── 20241206233511CreateGameChannelTables.ts │ ├── 20241221210019IncreasePlayerAliasIdentifierLength.ts │ ├── 20250126082032DropPlanActionTablesAddPlayerLimit.ts │ ├── 20250212031914AddLeaderboardRefreshIntervalAndEntryDeletedAt.ts │ ├── 20250213081652CreatePlayerPresenceTable.ts │ ├── 20250217004535DeletePlayerAliasAnonymisedColumn.ts │ ├── 20250219233504CascadePlayerPresenceAlias.ts │ ├── 20250402161623ModifyPlayerPropLengths.ts │ ├── 20250411180623AddGameChannelPrivateColumn.ts │ ├── 20250505131919CreateLeaderboardEntryPropTable.ts │ ├── 20250512220859AddCascadeDeleteRules.ts │ ├── 20250513222143AddPurgeAndWebsiteGameColumns.ts │ ├── 20250518214836CreateGameChannelPropTable.ts │ ├── 20250522212229AddGameChannelTemporaryMembershipColumn.ts │ ├── 20250531223353CreateGameChannelStoragePropTable.ts │ ├── clickhouse │ │ ├── 000CreateMigrationsTable.ts │ │ ├── 001CreateEventsTable.ts │ │ ├── 002CreateEventPropsTable.ts │ │ ├── 003CreateSocketEventsTable.ts │ │ ├── 004CreatePlayerGameStatSnapshotsTable.ts │ │ ├── 005MigrateEventsTimestampsToDate64.ts │ │ ├── 006CreatePlayerSessionsTable.ts │ │ └── index.ts │ └── index.ts ├── policies │ ├── api-key.policy.ts │ ├── api │ │ ├── event-api.policy.ts │ │ ├── game-channel-api.policy.ts │ │ ├── game-config-api.policy.ts │ │ ├── game-feedback-api.policy.ts │ │ ├── game-save-api.policy.ts │ │ ├── game-stat-api.policy.ts │ │ ├── leaderboard-api.policy.ts │ │ ├── player-api.policy.ts │ │ ├── player-auth-api.policy.ts │ │ ├── player-group-api.policy.ts │ │ └── player-presence-api.policy.ts │ ├── billing.policy.ts │ ├── checkScope.ts │ ├── data-export.policy.ts │ ├── email-confirmed-gate.ts │ ├── event.policy.ts │ ├── game-activity.policy.ts │ ├── game-channel.policy.ts │ ├── game-feedback.policy.ts │ ├── game-stat.policy.ts │ ├── game.policy.ts │ ├── headline.policy.ts │ ├── integration.policy.ts │ ├── invite.policy.ts │ ├── leaderboard.policy.ts │ ├── organisation.policy.ts │ ├── player-group.policy.ts │ ├── player.policy.ts │ ├── policy.ts │ └── user-type-gate.ts ├── services │ ├── api-key.service.ts │ ├── api │ │ ├── api-service.ts │ │ ├── event-api.service.ts │ │ ├── game-channel-api.service.ts │ │ ├── game-config-api.service.ts │ │ ├── game-feedback-api.service.ts │ │ ├── game-save-api.service.ts │ │ ├── game-stat-api.service.ts │ │ ├── health-check-api.service.ts │ │ ├── leaderboard-api.service.ts │ │ ├── player-api.service.ts │ │ ├── player-auth-api.service.ts │ │ ├── player-group-api.service.ts │ │ ├── player-presence-api.service.ts │ │ └── socket-ticket-api.service.ts │ ├── billing.service.ts │ ├── data-export.service.ts │ ├── event.service.ts │ ├── game-activity.service.ts │ ├── game-channel.service.ts │ ├── game-feedback.service.ts │ ├── game-stat.service.ts │ ├── game.service.ts │ ├── headline.service.ts │ ├── integration.service.ts │ ├── invite.service.ts │ ├── leaderboard.service.ts │ ├── organisation.service.ts │ ├── player-group.service.ts │ ├── player.service.ts │ ├── public │ │ ├── demo.service.ts │ │ ├── documentation.service.ts │ │ ├── invite-public.service.ts │ │ ├── user-public.service.ts │ │ └── webhook.service.ts │ └── user.service.ts ├── socket │ ├── index.ts │ ├── listeners │ │ ├── gameChannelListeners.ts │ │ └── playerListeners.ts │ ├── messages │ │ ├── socketError.ts │ │ ├── socketLogger.ts │ │ └── socketMessage.ts │ ├── router │ │ ├── createListener.ts │ │ └── socketRouter.ts │ ├── socketConnection.ts │ ├── socketEvent.ts │ └── socketTicket.ts └── tasks │ ├── archiveLeaderboardEntries.ts │ └── deleteInactivePlayers.ts ├── tests ├── auth │ └── api-auth.test.ts ├── entities │ ├── player-group-rule │ │ ├── casting.test.ts │ │ ├── equals-rule.test.ts │ │ ├── gt-rule.test.ts │ │ ├── gte-rule.test.ts │ │ ├── lt-rule.test.ts │ │ ├── lte-rule.test.ts │ │ └── set-rule.test.ts │ └── player-group │ │ └── ruleMode.test.ts ├── fixtures │ ├── DataExportFactory.ts │ ├── EventFactory.ts │ ├── GameActivityFactory.ts │ ├── GameChannelFactory.ts │ ├── GameChannelStoragePropFactory.ts │ ├── GameFactory.ts │ ├── GameFeedbackCategoryFactory.ts │ ├── GameFeedbackFactory.ts │ ├── GameSaveFactory.ts │ ├── GameStatFactory.ts │ ├── IntegrationConfigFactory.ts │ ├── IntegrationFactory.ts │ ├── InviteFactory.ts │ ├── LeaderboardEntryFactory.ts │ ├── LeaderboardFactory.ts │ ├── OrganisationFactory.ts │ ├── OrganisationPricingPlanFactory.ts │ ├── PlayerAliasFactory.ts │ ├── PlayerAuthActivityFactory.ts │ ├── PlayerAuthFactory.ts │ ├── PlayerFactory.ts │ ├── PlayerGameStatFactory.ts │ ├── PlayerGroupFactory.ts │ ├── PlayerPresenceFactory.ts │ ├── PricingPlanFactory.ts │ ├── UserFactory.ts │ └── UserPinnedGroupFactory.ts ├── lib │ ├── billing │ │ └── checkPricingPlanPlayerLimit.test.ts │ ├── integrations │ │ ├── steamworksSyncLeaderboards.test.ts │ │ └── steamworksSyncStats.test.ts │ ├── lang │ │ └── upperFirst.test.ts │ ├── messaging │ │ └── sendEmail.test.ts │ └── queues │ │ └── createQueue.test.ts ├── middlewares │ ├── continuity-middleware.test.ts │ └── player-auth-middleware.test.ts ├── migrateClickHouse.ts ├── policies │ └── policy.test.ts ├── run-tests.sh ├── seed.ts ├── services │ ├── _api │ │ ├── event-api │ │ │ └── post.test.ts │ │ ├── game-channel-api │ │ │ ├── delete.test.ts │ │ │ ├── get.test.ts │ │ │ ├── getStorage.test.ts │ │ │ ├── index.test.ts │ │ │ ├── invite.test.ts │ │ │ ├── join.test.ts │ │ │ ├── leave.test.ts │ │ │ ├── members.test.ts │ │ │ ├── post.test.ts │ │ │ ├── put.test.ts │ │ │ ├── putStorage.test.ts │ │ │ └── subscriptions.test.ts │ │ ├── game-config-api │ │ │ └── index.test.ts │ │ ├── game-feedback-api │ │ │ ├── indexCategories.test.ts │ │ │ └── post.test.ts │ │ ├── game-save-api │ │ │ ├── delete.test.ts │ │ │ ├── index.test.ts │ │ │ ├── patch.test.ts │ │ │ └── post.test.ts │ │ ├── game-stat-api │ │ │ ├── get.test.ts │ │ │ ├── globalHistory.test.ts │ │ │ ├── history.test.ts │ │ │ ├── index.test.ts │ │ │ ├── put.test.ts │ │ │ └── steamworksPut.test.ts │ │ ├── health-check-api │ │ │ └── index.test.ts │ │ ├── leaderboard-api │ │ │ ├── get.test.ts │ │ │ ├── post.test.ts │ │ │ └── steamworksPost.test.ts │ │ ├── player-api │ │ │ ├── get.test.ts │ │ │ ├── identify.test.ts │ │ │ ├── merge.test.ts │ │ │ ├── patch.test.ts │ │ │ └── steamworksIdentify.test.ts │ │ ├── player-auth-api │ │ │ ├── changeEmail.test.ts │ │ │ ├── changePassword.test.ts │ │ │ ├── delete.test.ts │ │ │ ├── forgotPassword.test.ts │ │ │ ├── login.test.ts │ │ │ ├── logout.test.ts │ │ │ ├── register.test.ts │ │ │ ├── resetPassword.test.ts │ │ │ ├── toggleVerification.test.ts │ │ │ └── verify.test.ts │ │ ├── player-group-api │ │ │ └── get.test.ts │ │ ├── player-presence-api │ │ │ ├── get.test.ts │ │ │ └── put.test.ts │ │ └── socket-ticket-api │ │ │ └── post.test.ts │ ├── _public │ │ ├── demo │ │ │ └── post.test.ts │ │ ├── documentation │ │ │ └── index.test.ts │ │ ├── invite-public │ │ │ └── get.test.ts │ │ ├── user-public │ │ │ ├── forgot-password.test.ts │ │ │ ├── login.test.ts │ │ │ ├── refresh.test.ts │ │ │ ├── register.test.ts │ │ │ ├── reset-password.test.ts │ │ │ ├── use-recovery-code.test.ts │ │ │ └── verify-2fa.test.ts │ │ └── webhook │ │ │ ├── invoice-finalized.test.ts │ │ │ ├── invoice-payment-failed.test.ts │ │ │ ├── subscription-created.test.ts │ │ │ ├── subscription-deleted.test.ts │ │ │ └── subscription-updated.test.ts │ ├── api-key │ │ ├── delete.test.ts │ │ ├── get.test.ts │ │ ├── post.test.ts │ │ ├── put.test.ts │ │ └── scopes.test.ts │ ├── billing │ │ ├── confirmPlan.test.ts │ │ ├── createCheckoutSession.test.ts │ │ ├── createPortalSession.test.ts │ │ ├── organisationPlan.test.ts │ │ ├── plans.test.ts │ │ └── usage.test.ts │ ├── data-export │ │ ├── entities.test.ts │ │ ├── generation.test.ts │ │ ├── included-data.test.ts │ │ ├── index.test.ts │ │ └── post.test.ts │ ├── event │ │ ├── breakdown.test.ts │ │ └── index.test.ts │ ├── game-activity │ │ └── index.test.ts │ ├── game-channel │ │ ├── delete.test.ts │ │ ├── index.test.ts │ │ ├── post.test.ts │ │ └── put.test.ts │ ├── game-feedback │ │ ├── deleteCategory.test.ts │ │ ├── index.test.ts │ │ ├── indexCategories.test.ts │ │ ├── postCategory.test.ts │ │ └── putCategory.test.ts │ ├── game-stat │ │ ├── delete.test.ts │ │ ├── index.test.ts │ │ ├── post.test.ts │ │ ├── put.test.ts │ │ └── updatePlayerStat.test.ts │ ├── game │ │ ├── patch.test.ts │ │ ├── post.test.ts │ │ └── settings.test.ts │ ├── headline │ │ └── get.test.ts │ ├── integration │ │ ├── delete.test.ts │ │ ├── index.test.ts │ │ ├── patch.test.ts │ │ ├── post.test.ts │ │ ├── syncLeaderboards.test.ts │ │ └── syncStats.test.ts │ ├── invite │ │ ├── index.test.ts │ │ └── post.test.ts │ ├── leaderboard │ │ ├── delete.test.ts │ │ ├── entries.test.ts │ │ ├── index.test.ts │ │ ├── post.test.ts │ │ ├── search.test.ts │ │ ├── steamworksDelete.test.ts │ │ ├── steamworksPost.test.ts │ │ ├── steamworksUpdateEntry.test.ts │ │ ├── steamworksUpdateLeaderboard.test.ts │ │ ├── updateEntry.test.ts │ │ └── updateLeaderboard.test.ts │ ├── organisation │ │ └── current.test.ts │ ├── player-group │ │ ├── delete.test.ts │ │ ├── index.test.ts │ │ ├── indexPinned.test.ts │ │ ├── post.test.ts │ │ ├── previewCount.test.ts │ │ ├── put.test.ts │ │ ├── rules.test.ts │ │ └── togglePinned.test.ts │ ├── player │ │ ├── authActivities.test.ts │ │ ├── events.test.ts │ │ ├── index.test.ts │ │ ├── patch.test.ts │ │ ├── post.test.ts │ │ ├── saves.test.ts │ │ └── stats.test.ts │ └── user │ │ ├── change-password.test.ts │ │ ├── confirm-2fa.test.ts │ │ ├── confirm-email.test.ts │ │ ├── create-recovery-codes.test.ts │ │ ├── disable-2fa.test.ts │ │ ├── enable-2fa.test.ts │ │ ├── get-me.test.ts │ │ ├── logout.test.ts │ │ └── view-recovery-codes.test.ts ├── setupTest.ts ├── socket │ ├── listeners │ │ ├── gameChannelListeners │ │ │ └── message.test.ts │ │ └── playerListeners │ │ │ └── identify.test.ts │ ├── messages │ │ └── socketLogger.test.ts │ ├── playerSession.test.ts │ ├── presence.test.ts │ ├── rateLimiting.test.ts │ ├── router.test.ts │ ├── server.test.ts │ └── socketEvents.test.ts ├── tasks │ ├── archiveLeaderboardEntries.test.ts │ └── deleteInactivePlayers.test.ts └── utils │ ├── clearEntities.ts │ ├── createAPIKeyAndToken.ts │ ├── createOrganisationAndGame.ts │ ├── createSocketIdentifyMessage.ts │ ├── createTestSocket.ts │ ├── createUserAndToken.ts │ └── userPermissionProvider.ts ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.mts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: TaloDev 2 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - release 5 | categories: 6 | - title: Breaking changes 7 | labels: 8 | - breaking 9 | - title: Features 10 | labels: 11 | - enhancement 12 | - title: Fixes 13 | labels: 14 | - fix 15 | - title: Other 16 | labels: 17 | - "*" 18 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | 18 | - uses: actions/cache@v4 19 | with: 20 | path: '**/node_modules' 21 | key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} 22 | 23 | - name: Install deps 24 | run: npm ci --prefer-offline 25 | 26 | - name: Build 27 | run: npm run build 28 | 29 | - name: Create GitHub release 30 | uses: softprops/action-gh-release@v2 31 | if: "!contains(github.event.head_commit.message, '--no-release')" 32 | with: 33 | generate_release_notes: true 34 | 35 | - name: Create Sentry release 36 | uses: getsentry/action-release@v1 37 | env: 38 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 39 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 40 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 41 | with: 42 | environment: prod 43 | sourcemaps: './dist' 44 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | tag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Check version change 14 | id: check 15 | uses: EndBug/version-check@v2 16 | 17 | - name: Create tag 18 | if: steps.check.outputs.changed == 'true' 19 | uses: tvdias/github-tagger@v0.0.2 20 | with: 21 | repo-token: ${{ secrets.GITHUB_TOKEN }} 22 | tag: ${{ steps.check.outputs.version }} 23 | 24 | - name: Trigger Docker workflow 25 | if: steps.check.outputs.changed == 'true' 26 | uses: actions/github-script@v7 27 | with: 28 | script: | 29 | github.rest.actions.createWorkflowDispatch({ 30 | owner: context.repo.owner, 31 | repo: context.repo.repo, 32 | workflow_id: 'docker.yml', 33 | ref: '${{ steps.check.outputs.version }}' 34 | }) 35 | 36 | - name: Trigger Release workflow 37 | if: steps.check.outputs.changed == 'true' 38 | uses: actions/github-script@v7 39 | with: 40 | script: | 41 | github.rest.actions.createWorkflowDispatch({ 42 | owner: context.repo.owner, 43 | repo: context.repo.repo, 44 | workflow_id: 'create-release.yml', 45 | ref: '${{ steps.check.outputs.version }}' 46 | }) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | thumbs.db 4 | dist/ 5 | *.log 6 | .env 7 | coverage/ 8 | backup.sql 9 | .eslintcache 10 | storage 11 | temp -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts AS base 2 | WORKDIR /usr/backend 3 | COPY tsconfig.json . 4 | COPY package.json . 5 | COPY package-lock.json . 6 | EXPOSE 80 7 | 8 | FROM base AS dev 9 | RUN npm ci 10 | CMD [ "npm", "run", "watch" ] 11 | 12 | FROM base AS build 13 | COPY tsconfig.build.json . 14 | RUN npm ci 15 | COPY src ./src 16 | RUN npm run build 17 | 18 | FROM base AS prod 19 | ENV NODE_ENV=production 20 | RUN npm ci 21 | COPY --from=build /usr/backend/dist . 22 | CMD [ "node", "index.js" ] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Talo Platform Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /_templates/generator/help/index.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | message: | 3 | hygen {bold generator new} --name [NAME] --action [ACTION] 4 | hygen {bold generator with-prompt} --name [NAME] --action [ACTION] 5 | --- -------------------------------------------------------------------------------- /_templates/generator/new/hello.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: _templates/<%= name %>/<%= action || 'new' %>/hello.ejs.t 3 | --- 4 | --- 5 | to: app/hello.js 6 | --- 7 | const hello = ``` 8 | Hello! 9 | This is your first hygen template. 10 | 11 | Learn what it can do here: 12 | 13 | https://github.com/jondot/hygen 14 | ``` 15 | 16 | console.log(hello) 17 | 18 | 19 | -------------------------------------------------------------------------------- /_templates/service/new/api-key-scope.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: "<%= (typeof api !== 'undefined') ? 'src/entities/api-key.ts' : null %>" 4 | after: export enum APIKeyScope 5 | --- 6 | READ_<%= h.changeCase.constantCase(name) %>S = 'read:<%= h.changeCase.camel(name) %>s', 7 | WRITE_<%= h.changeCase.constantCase(name) %>S = 'write:<%= h.changeCase.camel(name) %>s', -------------------------------------------------------------------------------- /_templates/service/new/api-policy.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "<%= (typeof api !== 'undefined') ? `src/policies/api/${name}-api.policy.ts` : null %>" 3 | --- 4 | import Policy from '../policy' 5 | import { PolicyResponse } from 'koa-clay' 6 | import { APIKeyScope } from '../../entities/api-key' 7 | 8 | export default class <%= h.changeCase.pascal(name) %>APIPolicy extends Policy { 9 | async post(): Promise { 10 | return await this.hasScope(APIKeyScope.WRITE_<%= h.changeCase.constantCase(name) %>S) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /_templates/service/new/api-route-import.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: "<%= (typeof api !== 'undefined') ? 'src/config/api-routes.ts' : null %>" 4 | after: import \{ service \} 5 | --- 6 | import <%= h.changeCase.pascal(name) %>APIService from '../services/api/<%= name %>-api.service' -------------------------------------------------------------------------------- /_templates/service/new/api-route.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: "<%= (typeof api !== 'undefined') ? 'src/config/api-routes.ts' : null %>" 4 | before: app\.use\(service 5 | --- 6 | app.use(service('/v1/<%= name %>s', new <%= h.changeCase.pascal(name) %>APIService())) -------------------------------------------------------------------------------- /_templates/service/new/api-service-test.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "<%= (typeof api !== 'undefined') ? `tests/services/_api/${name}-api/post.test.ts` : null %>" 3 | --- 4 | import request from 'supertest' 5 | import { APIKeyScope } from '../../../../src/entities/api-key' 6 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 7 | 8 | describe('<%= h.changeCase.sentenceCase(name) %> API service - post', () => { 9 | it('should create a <%= h.changeCase.noCase(name) %> if the scope is valid', async () => { 10 | const [, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_<%= h.changeCase.constantCase(name) %>S]) 11 | 12 | await request(app) 13 | .post('/v1/<%= name %>s') 14 | .auth(token, { type: 'bearer' }) 15 | .expect(200) 16 | }) 17 | 18 | it('should not create a <%= h.changeCase.noCase(name) %> if the scope is not valid', async () => { 19 | const [, token] = await createAPIKeyAndToken([]) 20 | 21 | await request(app) 22 | .post('/v1/<%= name %>s') 23 | .auth(token, { type: 'bearer' }) 24 | .expect(403) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /_templates/service/new/api-service.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "<%= (typeof api !== 'undefined') ? `src/services/api/${name}-api.service.ts` : null %>" 3 | --- 4 | import { HasPermission, Request, Response, Route, ForwardTo, forwardRequest } from 'koa-clay' 5 | import <%= h.changeCase.pascal(name) %>APIPolicy from '../../policies/api/<%= name %>-api.policy' 6 | import APIService from './api-service' 7 | import APIKey from '../../entities/api-key' 8 | 9 | export default class <%= h.changeCase.pascal(name) %>APIService extends APIService { 10 | @Route({ 11 | method: 'POST' 12 | }) 13 | @HasPermission(<%= h.changeCase.pascal(name) %>APIPolicy, 'post') 14 | @ForwardTo('<%= h.changeCase.dot(name) %>', 'post') 15 | async post(req: Request): Promise { 16 | const key: APIKey = await this.getAPIKey(req.ctx) 17 | return await forwardRequest(req, { 18 | params: { 19 | gameId: key.game.id.toString() 20 | } 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /_templates/service/new/entities-import.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: src/entities/index.ts 4 | before: import 5 | --- 6 | import <%= h.changeCase.pascal(name) %> from './<%= name %>' -------------------------------------------------------------------------------- /_templates/service/new/entities.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: src/entities/index.ts 4 | after: export default 5 | --- 6 | <%= h.changeCase.pascal(name) %>, -------------------------------------------------------------------------------- /_templates/service/new/entity.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/entities/<%= name %>.ts 3 | --- 4 | import { Entity, PrimaryKey, Property } from '@mikro-orm/mysql' 5 | 6 | @Entity() 7 | export default class <%= h.changeCase.pascal(name) %> { 8 | @PrimaryKey() 9 | id!: number 10 | 11 | @Property() 12 | createdAt: Date = new Date() 13 | 14 | @Property({ onUpdate: () => new Date() }) 15 | updatedAt: Date = new Date() 16 | 17 | constructor() { 18 | 19 | } 20 | 21 | toJSON() { 22 | return { 23 | id: this.id, 24 | createdAt: this.createdAt 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /_templates/service/new/factory.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: tests/fixtures/<%= h.changeCase.pascal(name) %>Factory.ts 3 | --- 4 | import { Factory } from 'hefty' 5 | import <%= h.changeCase.pascal(name) %> from '../../src/entities/<%= name %>' 6 | 7 | export default class <%= h.changeCase.pascal(name) %>Factory extends Factory<<%= h.changeCase.pascal(name) %>> { 8 | constructor() { 9 | super(<%= h.changeCase.pascal(name) %>) 10 | } 11 | 12 | protected definition(): void { 13 | this.state(() => ({ 14 | // TODO 15 | })) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /_templates/service/new/policy.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/policies/<%= name %>.policy.ts 3 | --- 4 | import Policy from './policy' 5 | import { PolicyResponse } from 'koa-clay' 6 | import { UserType } from '../entities/user' 7 | import UserTypeGate from './user-type-gate' 8 | 9 | export default class <%= h.changeCase.pascal(name) %>Policy extends Policy { 10 | async get(): Promise { 11 | return true 12 | } 13 | 14 | @UserTypeGate([UserType.ADMIN, UserType.DEV], 'create <%= h.inflection.humanize(name, true) %>') 15 | async post(): Promise { 16 | return true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /_templates/service/new/protected-route-import.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: src/config/protected-routes.ts 4 | after: import \{ service, ServiceOpts \} 5 | --- 6 | import <%= h.changeCase.pascal(name) %>Service from '../services/<%= name %>.service' -------------------------------------------------------------------------------- /_templates/service/new/protected-route.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | inject: true 3 | to: src/config/protected-routes.ts 4 | before: app\.use\(service 5 | --- 6 | app.use(service('/<%= name %>s', new <%= h.changeCase.pascal(name) %>Service(), serviceOpts)) -------------------------------------------------------------------------------- /_templates/service/new/service-test.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: tests/services/<%= name %>/get.test.ts 3 | --- 4 | import request from 'supertest' 5 | import createUserAndToken from '../../utils/createUserAndToken' 6 | import <%= h.changeCase.pascal(name) %>Factory from '../../fixtures/<%= h.changeCase.pascal(name) %>Factory' 7 | 8 | describe('<%= h.changeCase.sentenceCase(name) %> service - get', () => { 9 | it('should return a of <%= h.changeCase.noCase(name) %>s', async () => { 10 | const [token] = await createUserAndToken() 11 | const <%= h.changeCase.camel(name) %> = await new <%= h.changeCase.pascal(name) %>Factory().one() 12 | await em.persistAndFlush(<%= h.changeCase.camel(name) %>) 13 | 14 | await request(app) 15 | .get(`/<%= name %>/${<%= h.changeCase.camel(name) %>.id}`) 16 | .auth(token, { type: 'bearer' }) 17 | .expect(200) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /_templates/service/new/service.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/services/<%= name %>.service.ts 3 | --- 4 | import { EntityManager } from '@mikro-orm/mysql' 5 | import { HasPermission, Service, Request, Response, Route, Validate } from 'koa-clay' 6 | import <%= h.changeCase.pascal(name) %> from '../entities/<%= name %>' 7 | import <%= h.changeCase.pascal(name) %>Policy from '../policies/<%= name %>.policy' 8 | 9 | export default class <%= h.changeCase.pascal(name) %>Service extends Service { 10 | @Route({ 11 | method: 'GET', 12 | path: '/:id' 13 | }) 14 | @HasPermission(<%= h.changeCase.pascal(name) %>Policy, 'get') 15 | async get(req: Request): Promise { 16 | const { id } = req.params 17 | const em: EntityManager = req.ctx.em 18 | const <%= h.changeCase.camel(name) %> = await em.getRepository(<%= h.changeCase.pascal(name) %>).findOne(Number(id)) 19 | 20 | return { 21 | status: 200, 22 | body: { 23 | <%= h.changeCase.camel(name) %> 24 | } 25 | } 26 | } 27 | 28 | @Route({ 29 | method: 'POST' 30 | }) 31 | @Validate({ 32 | body: [] 33 | }) 34 | @HasPermission(<%= h.changeCase.pascal(name) %>Policy, 'post') 35 | async post(req: Request): Promise { 36 | const {} = req.body 37 | const em: EntityManager = req.ctx.em 38 | 39 | const <%= h.changeCase.camel(name) %> = new <%= h.changeCase.pascal(name) %>() 40 | await em.persistAndFlush(<%= h.changeCase.camel(name) %>) 41 | 42 | return { 43 | status: 200, 44 | body: { 45 | <%= h.changeCase.camel(name) %> 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - '/src/config' 3 | - '/src/migrations' 4 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test-redis: 3 | image: bitnami/redis:7.4 4 | command: 5 | environment: 6 | - REDIS_PASSWORD=${REDIS_PASSWORD} 7 | restart: unless-stopped 8 | 9 | stripe-api: 10 | image: stripe/stripe-mock:latest 11 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test-db: 3 | image: mysql:8.4 4 | command: --mysql-native-password=ON 5 | environment: 6 | - MYSQL_DATABASE=${DB_NAME} 7 | - MYSQL_ROOT_PASSWORD=${DB_PASS} 8 | restart: always 9 | healthcheck: 10 | test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] 11 | interval: 2s 12 | timeout: 2s 13 | retries: 10 14 | ports: 15 | - ${DB_PORT}:3306 16 | volumes: 17 | - test-data:/var/lib/mysql 18 | networks: 19 | - test-network 20 | 21 | test-redis: 22 | image: arm64v8/redis:7-alpine 23 | command: redis-server --requirepass ${REDIS_PASSWORD} 24 | restart: unless-stopped 25 | ports: 26 | - ${REDIS_PORT}:6379 27 | depends_on: 28 | test-db: 29 | condition: service_healthy 30 | networks: 31 | - test-network 32 | 33 | test-clickhouse: 34 | image: clickhouse/clickhouse-server:24.12-alpine 35 | environment: 36 | CLICKHOUSE_USER: ${CLICKHOUSE_USER} 37 | CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} 38 | CLICKHOUSE_DB: ${CLICKHOUSE_DB} 39 | restart: always 40 | ports: 41 | - ${CLICKHOUSE_PORT}:8123 42 | networks: 43 | - test-network 44 | 45 | stripe-api: 46 | image: stripe/stripe-mock:latest-arm64 47 | ports: 48 | - 12111:12111 49 | - 12112:12112 50 | networks: 51 | - test-network 52 | 53 | volumes: 54 | test-data: 55 | 56 | networks: 57 | test-network: 58 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | build: 4 | context: . 5 | target: dev 6 | image: backend 7 | ports: 8 | - 3000:80 9 | volumes: 10 | - .env:/usr/backend/.env 11 | - ./src:/usr/backend/src 12 | - ./tests:/usr/backend/tests 13 | depends_on: 14 | - db 15 | - redis 16 | - clickhouse 17 | 18 | db: 19 | image: mysql:8.4 20 | command: --mysql-native-password=ON 21 | environment: 22 | - MYSQL_DATABASE=${DB_NAME} 23 | - MYSQL_ROOT_PASSWORD=${DB_PASS} 24 | restart: always 25 | ports: 26 | - 3306:3306 27 | volumes: 28 | - data:/var/lib/mysql 29 | 30 | redis: 31 | image: arm64v8/redis:7-alpine 32 | command: redis-server --requirepass ${REDIS_PASSWORD} 33 | restart: unless-stopped 34 | ports: 35 | - 6379:6379 36 | 37 | clickhouse: 38 | image: clickhouse/clickhouse-server:24.12-alpine 39 | environment: 40 | CLICKHOUSE_USER: ${CLICKHOUSE_USER} 41 | CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} 42 | CLICKHOUSE_DB: ${CLICKHOUSE_DB} 43 | restart: always 44 | ports: 45 | - ${CLICKHOUSE_PORT}:8123 46 | - 9004:9004 47 | volumes: 48 | - clickhouse-data:/var/lib/clickhouse 49 | 50 | volumes: 51 | data: 52 | clickhouse-data: 53 | -------------------------------------------------------------------------------- /envs/.dockerignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TaloDev/backend/3dbcde29363a924f46f5bacffe88024c884d2498/envs/.dockerignore -------------------------------------------------------------------------------- /envs/.env.dev: -------------------------------------------------------------------------------- 1 | JWT_SECRET=abc123 2 | DASHBOARD_URL=http://localhost:8080 3 | 4 | DB_HOST=db 5 | DB_PORT=3306 6 | DB_USER=root 7 | DB_NAME=gs_dev 8 | DB_PASS=password 9 | 10 | REDIS_PASSWORD=password 11 | 12 | CLICKHOUSE_HOST=clickhouse 13 | CLICKHOUSE_PORT=8123 14 | CLICKHOUSE_USER=gs_ch 15 | CLICKHOUSE_PASSWORD=password 16 | CLICKHOUSE_DB=gs_ch_dev 17 | 18 | DEMO_ORGANISATION_NAME="Talo Demo" 19 | 20 | EMAIL_DRIVER=log 21 | EMAIL_HOST= 22 | EMAIL_PORT= 23 | EMAIL_USERNAME= 24 | EMAIL_PASSWORD= 25 | 26 | AUTO_CONFIRM_EMAIL=false 27 | FROM_EMAIL=hello@trytalo.com 28 | 29 | SENTRY_ENV=dev 30 | SENTRY_DSN= 31 | 32 | RECOVERY_CODES_SECRET=tc0d8e0h0lqv5isajfjw0iivj5pc3d95 33 | STEAM_INTEGRATION_SECRET=PjBw8vy8ZbFqXvZwAABWfbhfXvJ32idf 34 | API_SECRET=YgGBEely4gOIzXGOf44N4IWtFwMCzw8O 35 | 36 | STRIPE_KEY= 37 | -------------------------------------------------------------------------------- /envs/.env.test: -------------------------------------------------------------------------------- 1 | DB_HOST=127.0.0.1 2 | DB_PORT=3307 3 | DB_NAME=gs_test 4 | DB_PASS=password 5 | 6 | CLICKHOUSE_HOST=127.0.0.1 7 | CLICKHOUSE_PORT=8124 8 | CLICKHOUSE_USER=gs_ch 9 | CLICKHOUSE_PASSWORD=password 10 | CLICKHOUSE_DB=gs_ch_test 11 | 12 | EMAIL_DRIVER=relay 13 | SENTRY_DSN= 14 | 15 | MIKRO_ORM_POOL_MIN=0 16 | MIKRO_ORM_POOL_MAX=20 17 | 18 | STRIPE_KEY=sk_test_abc123 19 | STRIPE_WEBHOOK_SECRET=whsec_abc123 20 | 21 | REDIS_HOST=127.0.0.1 22 | REDIS_PORT=6380 23 | REDIS_PASSWORD=password 24 | 25 | COMPOSE_IGNORE_ORPHANS=1 26 | 27 | API_SECRET=abcdefghijklmnopqrstuvwxyz123456 28 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchDepTypes": ["devDependencies"], 9 | "matchUpdateTypes": ["minor", "patch"], 10 | "automerge": true 11 | }, 12 | { 13 | "matchDepTypes": ["dependencies"], 14 | "matchUpdateTypes": ["patch"], 15 | "excludePackagePrefixes": ["@mikro-orm/"], 16 | "enabled": false 17 | }, 18 | { 19 | "matchSourceUrls": [ 20 | "https://github.com/mikro-orm/mikro-orm" 21 | ], 22 | "matchUpdateTypes": ["minor", "patch"], 23 | "groupName": "mikro-orm" 24 | } 25 | ], 26 | "platformAutomerge": true, 27 | "schedule": ["before 3am on the first day of the month"] 28 | } 29 | -------------------------------------------------------------------------------- /src/config/mikro-orm.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import entities from '../entities' 3 | import subscribers from '../entities/subscribers' 4 | import migrationsList from '../migrations' 5 | import { TsMorphMetadataProvider } from '@mikro-orm/reflection' 6 | import { Migrator } from '@mikro-orm/migrations' 7 | import { defineConfig } from '@mikro-orm/mysql' 8 | 9 | export default defineConfig({ 10 | entities, 11 | host: process.env.DB_HOST, 12 | port: Number(process.env.DB_PORT), 13 | dbName: process.env.DB_NAME, 14 | user: process.env.DB_USER, 15 | password: process.env.DB_PASS, 16 | migrations: { 17 | migrationsList, 18 | path: 'src/migrations' // for generating migrations via the cli 19 | }, 20 | subscribers, 21 | metadataProvider: TsMorphMetadataProvider, 22 | extensions: [Migrator], 23 | pool: { 24 | min: Number(process.env.MIKRO_ORM_POOL_MIN) || 2, 25 | max: Number(process.env.MIKRO_ORM_POOL_MAX) || 10 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /src/config/providers.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import * as Sentry from '@sentry/node' 3 | import ormConfig from './mikro-orm.config' 4 | import { MikroORM } from '@mikro-orm/mysql' 5 | import tracingMiddleware from '../middlewares/tracing-middleware' 6 | import createEmailQueue from '../lib/queues/createEmailQueue' 7 | import createClickHouseClient from '../lib/clickhouse/createClient' 8 | import { runClickHouseMigrations } from '../migrations/clickhouse' 9 | import initScheduledTasks from './scheduled-tasks' 10 | 11 | export default async function initProviders(app: Koa, isTest: boolean) { 12 | try { 13 | const orm = await MikroORM.init(ormConfig) 14 | app.context.em = orm.em 15 | 16 | if (!isTest) { 17 | const migrator = orm.getMigrator() 18 | await migrator.up() 19 | } 20 | } catch (err) { 21 | console.error(err) 22 | process.exit(1) 23 | } 24 | 25 | app.context.emailQueue = createEmailQueue() 26 | 27 | if (!isTest) { 28 | await initScheduledTasks() 29 | } 30 | 31 | Sentry.init({ 32 | dsn: process.env.SENTRY_DSN, 33 | environment: process.env.SENTRY_ENV, 34 | tracesSampleRate: 0.2, 35 | maxValueLength: 4096, 36 | integrations: [ 37 | ...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations() 38 | ] 39 | }) 40 | 41 | app.context.clickhouse = createClickHouseClient() 42 | if (!isTest) { 43 | await runClickHouseMigrations(app.context.clickhouse) 44 | } 45 | 46 | app.use(tracingMiddleware) 47 | } 48 | -------------------------------------------------------------------------------- /src/config/public-routes.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import { service, ServiceOpts } from 'koa-clay' 3 | import DemoService from '../services/public/demo.service' 4 | import DocumentationService from '../services/public/documentation.service' 5 | import InvitePublicService from '../services/public/invite-public.service' 6 | import UserPublicService from '../services/public/user-public.service' 7 | import WebhookService from '../services/public/webhook.service' 8 | 9 | export default function configurePublicRoutes(app: Koa) { 10 | const serviceOpts: ServiceOpts = { 11 | docs: { 12 | hidden: true 13 | } 14 | } 15 | 16 | app.use(service('/public/docs', new DocumentationService(), serviceOpts)) 17 | app.use(service('/public/webhooks', new WebhookService(), serviceOpts)) 18 | app.use(service('/public/invites', new InvitePublicService(), serviceOpts)) 19 | app.use(service('/public/users', new UserPublicService(), serviceOpts)) 20 | app.use(service('/public/demo', new DemoService(), serviceOpts)) 21 | } 22 | -------------------------------------------------------------------------------- /src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { Context } from 'koa' 3 | 4 | const redisConfig = { 5 | host: process.env.REDIS_HOST ?? 'redis', 6 | password: process.env.REDIS_PASSWORD, 7 | port: process.env.REDIS_PORT ? Number(process.env.REDIS_PORT) : 6379 8 | } 9 | 10 | export default redisConfig 11 | 12 | export function createRedisConnection(ctx: Context): Redis { 13 | if (ctx.state.redis instanceof Redis) return ctx.state.redis 14 | 15 | const redis = new Redis(redisConfig) 16 | ctx.state.redis = redis 17 | return redis 18 | } 19 | -------------------------------------------------------------------------------- /src/config/scheduled-tasks.ts: -------------------------------------------------------------------------------- 1 | import archiveLeaderboardEntries from '../tasks/archiveLeaderboardEntries' 2 | import createQueue from '../lib/queues/createQueue' 3 | import deleteInactivePlayers from '../tasks/deleteInactivePlayers' 4 | 5 | const ARCHIVE_LEADERBOARD_ENTRIES = 'archive-leaderboard-entries' 6 | const DELETE_INACTIVE_PLAYERS = 'delete-inactive-players' 7 | 8 | export default async function initScheduledTasks() { 9 | await Promise.all([ 10 | createQueue(ARCHIVE_LEADERBOARD_ENTRIES, archiveLeaderboardEntries).upsertJobScheduler( 11 | `${ARCHIVE_LEADERBOARD_ENTRIES}-scheduler`, 12 | { pattern: '0 0 0 * * *' }, 13 | { name: `${ARCHIVE_LEADERBOARD_ENTRIES}-job` } 14 | ), 15 | createQueue(DELETE_INACTIVE_PLAYERS, deleteInactivePlayers).upsertJobScheduler( 16 | `${DELETE_INACTIVE_PLAYERS}-scheduler`, 17 | { pattern: '0 0 0 1 * *' }, 18 | { name: `${DELETE_INACTIVE_PLAYERS}-job` } 19 | ) 20 | ]) 21 | } 22 | -------------------------------------------------------------------------------- /src/docs/api-docs.ts: -------------------------------------------------------------------------------- 1 | import { RouteDocs } from 'koa-clay' 2 | 3 | type APIDocs = { 4 | [key in keyof T]?: RouteDocs 5 | } 6 | 7 | export default APIDocs 8 | -------------------------------------------------------------------------------- /src/docs/game-config-api.docs.ts: -------------------------------------------------------------------------------- 1 | import GameConfigAPIService from '../services/api/game-config-api.service' 2 | import APIDocs from './api-docs' 3 | 4 | const GameConfigAPIDocs: APIDocs = { 5 | index: { 6 | description: 'Get the live config for the game', 7 | samples: [ 8 | { 9 | title: 'Sample response', 10 | sample: { 11 | config: [ 12 | { key: 'xpRate', value: '1.5' }, 13 | { key: 'maxLevel', value: '80' }, 14 | { key: 'halloweenEventEnabled', value: 'false' } 15 | ] 16 | } 17 | } 18 | ] 19 | } 20 | } 21 | 22 | export default GameConfigAPIDocs 23 | -------------------------------------------------------------------------------- /src/docs/socket-tickets-api.docs.ts: -------------------------------------------------------------------------------- 1 | import SocketTicketAPIService from '../services/api/socket-ticket-api.service' 2 | import APIDocs from './api-docs' 3 | 4 | const SocketTicketAPIDocs: APIDocs = { 5 | post: { 6 | description: 'Create a socket ticket (expires after 5 minutes)', 7 | samples: [ 8 | { 9 | title: 'Sample response', 10 | sample: { 11 | ticket: '6c6ef345-0ac3-4edc-a221-b807fbbac4ac' 12 | } 13 | } 14 | ] 15 | } 16 | } 17 | 18 | export default SocketTicketAPIDocs 19 | -------------------------------------------------------------------------------- /src/emails/confirm-email-mail.ts: -------------------------------------------------------------------------------- 1 | import User from '../entities/user' 2 | import Mail from './mail' 3 | 4 | export default class ConfirmEmail extends Mail { 5 | constructor(user: User, code: string) { 6 | super(user.email, 'Welcome to Talo', `Hi ${user.username}! Thanks for signing up to Talo. You'll need to confirm your account to get started.`) 7 | 8 | this.title = 'Welcome!' 9 | this.mainText = `Hi ${user.username}! Thanks for signing up to Talo. To confirm your account, enter the following code into the dashboard: ${code}` 10 | 11 | this.ctaLink = process.env.DASHBOARD_URL! 12 | this.ctaText = 'Go to your dashboard' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/emails/data-export-ready-mail.ts: -------------------------------------------------------------------------------- 1 | import Mail, { AttachmentData } from './mail' 2 | 3 | export default class DataExportReady extends Mail { 4 | constructor(to: string, attachments: AttachmentData[]) { 5 | super(to, 'Your data export is ready', 'We\'ve finished processing your data export request and it is now available to download.') 6 | 7 | this.title = 'Your data export is ready' 8 | this.mainText = 'We\'ve attached it to this email for you.' 9 | 10 | this.why = 'You are receiving this email because you requested a data export' 11 | 12 | this.attachments = attachments 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/emails/join-organisation-mail.ts: -------------------------------------------------------------------------------- 1 | import Invite from '../entities/invite' 2 | import Mail from './mail' 3 | 4 | export default class JoinOrganisation extends Mail { 5 | constructor(invite: Invite) { 6 | super(invite.email, 'You\'ve been invited to Talo', `Hey there, you've been invited by ${invite.invitedByUser.username} to join them and the rest of ${invite.organisation.name} on Talo.`) 7 | 8 | this.title = `Join ${invite.organisation.name} on Talo` 9 | this.mainText = `Hey there, you've been invited by ${invite.invitedByUser.username} to join them on Talo.` 10 | 11 | this.ctaLink = `${process.env.DASHBOARD_URL}/accept/${invite.token}` 12 | this.ctaText = 'Accept invite' 13 | 14 | this.why = 'You are receiving this email because you were invited to create a Talo account' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/emails/plan-cancelled-mail.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import Organisation from '../entities/organisation' 3 | import Mail from './mail' 4 | 5 | export default class PlanCancelled extends Mail { 6 | constructor(organisation: Organisation) { 7 | const formattedDate = format(new Date(organisation.pricingPlan.endDate!), 'do MMM yyyy') 8 | 9 | super(organisation.email, 'Subscription cancelled', `Your subscription has been successfully cancelled and will end on ${formattedDate}. In the mean time, you can renew your plan through the billing portal if you change your mind.`) 10 | 11 | this.title = 'Subscription cancelled' 12 | this.mainText = `Your subscription has been successfully cancelled and will end on ${formattedDate}. After this date, you will be downgraded to our free plan.

You will need to contact support about removing users if you have more members in your organisation than the user seat limit for the free plan.

In the mean time, you can renew your plan through the billing portal if you change your mind.` 13 | 14 | this.why = 'You are receiving this email because your Talo subscription was updated' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/emails/plan-invoice-mail.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import Stripe from 'stripe' 3 | import Organisation from '../entities/organisation' 4 | import Mail from './mail' 5 | import { USD } from '@dinero.js/currencies' 6 | import { dinero, toDecimal } from 'dinero.js' 7 | 8 | export default class PlanInvoice extends Mail { 9 | constructor(organisation: Organisation, invoice: Stripe.Invoice) { 10 | super(organisation.email, 'Your invoice is ready', '') 11 | 12 | this.title = 'Thanks for using Talo!' 13 | this.mainText = `Your ${format(new Date(), 'MMMM yyyy')} invoice is ready.

The balance due is: ${this.getPrice(invoice.amount_due)}.

The balance will be automatically charged to your card so you don't need to do anything.` 14 | 15 | this.ctaLink = invoice.hosted_invoice_url! 16 | this.ctaText = 'View invoice' 17 | 18 | this.why = 'You are receiving this email because your Talo subscription was updated' 19 | } 20 | 21 | private getPrice(amount: number): string { 22 | const d = dinero({ amount, currency: USD }) 23 | const transformer = ({ value }: { value: unknown }) => `$${value}` 24 | return toDecimal(d, transformer) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/emails/plan-payment-failed.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import Stripe from 'stripe' 3 | import Organisation from '../entities/organisation' 4 | import Mail from './mail' 5 | 6 | export default class PlanPaymentFailed extends Mail { 7 | constructor(organisation: Organisation, invoice: Stripe.Invoice) { 8 | super(organisation.email, 'Payment failed', `We attempted to charge your card for your ${format(new Date(), 'MMMM yyyy')} invoice, however we were unable to do so.`) 9 | 10 | this.title = 'Payment failed' 11 | this.mainText = `We attempted to charge your card for your ${format(new Date(), 'MMMM yyyy')} invoice, however we were unable to do so.

Please use the link below to update your payment details:` 12 | 13 | this.ctaLink = invoice.hosted_invoice_url! 14 | this.ctaText = 'View invoice' 15 | 16 | this.why = 'You are receiving this email because your Talo subscription was updated' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/emails/plan-renewed-mail.ts: -------------------------------------------------------------------------------- 1 | import Organisation from '../entities/organisation' 2 | import Mail from './mail' 3 | 4 | export default class PlanRenewed extends Mail { 5 | constructor(organisation: Organisation) { 6 | super(organisation.email, 'Subscription renewed', 'Thank you for choosing to continue your Talo subscription. Your subscription has been successfully renewed and will carry on as normal.') 7 | 8 | this.title = 'Subscription renewed' 9 | this.mainText = 'Thanks for renewing your subscription! Your renewal was successful and your subscription will carry on as normal.' 10 | 11 | this.why = 'You are receiving this email because your Talo subscription was updated' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/emails/plan-upgraded-mail.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | import Organisation from '../entities/organisation' 3 | import Mail from './mail' 4 | 5 | export default class PlanUpgraded extends Mail { 6 | constructor(organisation: Organisation, price: Stripe.Price, product: Stripe.Product) { 7 | super(organisation.email, 'Your new Talo subscription', `Your plan has been successfully changed. Your organisation has now been moved to the ${product.name}, recurring ${price.recurring!.interval}ly. An invoice for this will be sent to you when your new plan starts.`) 8 | 9 | this.title = 'Your plan has changed' 10 | this.mainText = `Your organisation has now been moved to the ${product.name}, recurring ${price.recurring!.interval}ly. An invoice for this will be sent to you when your new plan starts.` 11 | 12 | this.why = 'You are receiving this email because your Talo subscription was updated' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/emails/player-auth-code-mail.ts: -------------------------------------------------------------------------------- 1 | import PlayerAlias from '../entities/player-alias' 2 | import Mail from './mail' 3 | 4 | export default class PlayerAuthCode extends Mail { 5 | constructor(alias: PlayerAlias, code: string) { 6 | super(alias.player.auth!.email!, `Your ${alias.player.game.name} verification code`, `Hi ${alias.identifier}, here's your verification code to login to ${alias.player.game.name}.`) 7 | 8 | this.title = `Login to ${alias.player.game.name}` 9 | this.mainText = `Hi ${alias.identifier}, your verification code is: ${code}.
This code is only valid for 5 minutes.` 10 | 11 | this.footer = 'Didn\'t request a code?' 12 | this.footerText = 'Your account is still secure, however, you should update your password as soon as possible.' 13 | 14 | this.why = `You are receiving this email because you enabled email verification for your ${alias.player.game.name} account` 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/emails/player-auth-reset-password-mail.ts: -------------------------------------------------------------------------------- 1 | import PlayerAlias from '../entities/player-alias' 2 | import Mail from './mail' 3 | 4 | export default class PlayerAuthResetPassword extends Mail { 5 | constructor(alias: PlayerAlias, code: string) { 6 | super(alias.player.auth!.email!, `Reset your ${alias.player.game.name} password`, `Hi ${alias.identifier}. A password reset was requested for your account. If you didn't request this you can safely ignore this email.`) 7 | 8 | this.title = 'Reset your password' 9 | this.mainText = `Hi ${alias.identifier}, a password reset requested was created for your ${alias.player.game.name} account.

Your reset code is: ${code}.
This code is only valid for 15 minutes.` 10 | 11 | this.footer = 'Didn\'t request a code?' 12 | this.footerText = 'Your account is still secure and you can safely ignore this email.' 13 | 14 | this.why = `You are receiving this email because your ${alias.player.game.name} account is associated with this email address` 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/emails/reset-password.ts: -------------------------------------------------------------------------------- 1 | import User from '../entities/user' 2 | import Mail from './mail' 3 | 4 | export default class ResetPassword extends Mail { 5 | constructor(user: User, accessToken: string) { 6 | super(user.email, 'Reset your password', `Hi ${user.username}, a password reset was requested for your account. If you didn't request this you can safely ignore this email.`) 7 | 8 | this.title = 'Reset your password' 9 | this.mainText = `Hi ${user.username},

A password reset was requested for your account - please follow the link below to create a new password. This link is only valid for 15 minutes.

If you didn't request this you can safely ignore this email.` 10 | 11 | this.ctaLink = process.env.DASHBOARD_URL + `/reset-password?token=${accessToken}` 12 | this.ctaText = 'Reset your password' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/entities/failed-job.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | 3 | @Entity() 4 | export default class FailedJob { 5 | @PrimaryKey() 6 | id!: number 7 | 8 | @Property() 9 | queue!: string 10 | 11 | @Property({ type: 'json', nullable: true }) 12 | payload!: { [key: string]: unknown } 13 | 14 | @Property() 15 | reason!: string 16 | 17 | @Property({ columnType: 'text' }) 18 | stack!: string 19 | 20 | @Property() 21 | failedAt: Date = new Date() 22 | } 23 | -------------------------------------------------------------------------------- /src/entities/game-channel-prop.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import { MAX_KEY_LENGTH, MAX_VALUE_LENGTH } from './prop' 3 | import GameChannel from './game-channel' 4 | 5 | @Entity() 6 | export default class GameChannelProp { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @ManyToOne(() => GameChannel, { deleteRule: 'cascade' }) 11 | gameChannel: GameChannel 12 | 13 | @Property({ length: MAX_KEY_LENGTH }) 14 | key: string 15 | 16 | @Property({ length: MAX_VALUE_LENGTH }) 17 | value: string 18 | 19 | @Property() 20 | createdAt: Date = new Date() 21 | 22 | constructor(gameChannel: GameChannel, key: string, value: string) { 23 | this.gameChannel = gameChannel 24 | this.key = key 25 | this.value = value 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/entities/game-feedback.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import GameFeedbackCategory from './game-feedback-category' 3 | import { Required } from 'koa-clay' 4 | import PlayerAlias from './player-alias' 5 | 6 | @Entity() 7 | export default class GameFeedback { 8 | @PrimaryKey() 9 | id!: number 10 | 11 | @ManyToOne(() => GameFeedbackCategory, { nullable: false, deleteRule: 'cascade', eager: true }) 12 | category: GameFeedbackCategory 13 | 14 | @ManyToOne(() => PlayerAlias, { nullable: false, deleteRule: 'cascade' }) 15 | playerAlias: PlayerAlias 16 | 17 | @Required() 18 | @Property({ type: 'text' }) 19 | comment!: string 20 | 21 | @Property() 22 | anonymised!: boolean 23 | 24 | @Property() 25 | createdAt: Date = new Date() 26 | 27 | @Property({ onUpdate: () => new Date() }) 28 | updatedAt: Date = new Date() 29 | 30 | constructor(category: GameFeedbackCategory, playerAlias: PlayerAlias) { 31 | this.category = category 32 | this.playerAlias = playerAlias 33 | } 34 | 35 | toJSON() { 36 | return { 37 | id: this.id, 38 | category: this.category, 39 | comment: this.comment, 40 | anonymised: this.anonymised, 41 | playerAlias: this.anonymised ? null : this.playerAlias, 42 | devBuild: this.playerAlias.player.isDevBuild(), 43 | createdAt: this.createdAt 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/entities/game-save.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import Player from './player' 3 | 4 | @Entity() 5 | export default class GameSave { 6 | @PrimaryKey() 7 | id!: number 8 | 9 | @Property() 10 | name: string 11 | 12 | @Property({ type: 'json' }) 13 | content!: { [key: string]: unknown } 14 | 15 | @ManyToOne(() => Player, { deleteRule: 'cascade' }) 16 | player: Player 17 | 18 | @Property() 19 | createdAt: Date = new Date() 20 | 21 | @Property({ onUpdate: () => new Date() }) 22 | updatedAt: Date = new Date() 23 | 24 | constructor(name: string, player: Player) { 25 | this.name = name 26 | this.player = player 27 | } 28 | 29 | toJSON() { 30 | return { 31 | id: this.id, 32 | name: this.name, 33 | content: this.content, 34 | createdAt: this.createdAt, 35 | updatedAt: this.updatedAt 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/entities/game-secret.ts: -------------------------------------------------------------------------------- 1 | import { Entity, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import { decrypt, encrypt } from '../lib/crypto/string-encryption' 3 | import Game from './game' 4 | import crypto from 'crypto' 5 | 6 | @Entity() 7 | export default class GameSecret { 8 | @PrimaryKey() 9 | id!: number 10 | 11 | @OneToOne(() => Game, (game) => game.apiSecret) 12 | game!: Game 13 | 14 | @Property({ hidden: true }) 15 | secret: string 16 | 17 | constructor() { 18 | this.secret = this.generateSecret() 19 | } 20 | 21 | generateSecret(): string { 22 | if ((process.env.API_SECRET ?? '').length !== 32) { 23 | throw new Error('API_SECRET must be 32 characters long') 24 | } 25 | 26 | const secret = crypto.randomBytes(48).toString('hex') 27 | return encrypt(secret, process.env.API_SECRET!) 28 | } 29 | 30 | getPlainSecret(): string { 31 | return decrypt(this.secret, process.env.API_SECRET!) 32 | } 33 | 34 | /* v8 ignore start */ 35 | toJSON() { 36 | return { 37 | id: this.id 38 | } 39 | } 40 | /* v8 ignore stop */ 41 | } 42 | -------------------------------------------------------------------------------- /src/entities/leaderboard-entry-prop.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import LeaderboardEntry from './leaderboard-entry' 3 | import { MAX_KEY_LENGTH, MAX_VALUE_LENGTH } from './prop' 4 | 5 | @Entity() 6 | export default class LeaderboardEntryProp { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @ManyToOne(() => LeaderboardEntry, { deleteRule: 'cascade' }) 11 | leaderboardEntry: LeaderboardEntry 12 | 13 | @Property({ length: MAX_KEY_LENGTH }) 14 | key: string 15 | 16 | @Property({ length: MAX_VALUE_LENGTH }) 17 | value: string 18 | 19 | @Property() 20 | createdAt: Date = new Date() 21 | 22 | constructor(leaderboardEntry: LeaderboardEntry, key: string, value: string) { 23 | this.leaderboardEntry = leaderboardEntry 24 | this.key = key 25 | this.value = value 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/entities/organisation-pricing-plan.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import Stripe from 'stripe' 3 | import Organisation from './organisation' 4 | import PricingPlan from './pricing-plan' 5 | 6 | @Entity() 7 | export default class OrganisationPricingPlan { 8 | @PrimaryKey() 9 | id!: number 10 | 11 | @OneToOne(() => Organisation, (organisation) => organisation.pricingPlan) 12 | organisation!: Organisation 13 | 14 | @ManyToOne(() => PricingPlan, { eager: true }) 15 | pricingPlan: PricingPlan 16 | 17 | @Property({ type: 'string' }) 18 | status: Stripe.Subscription.Status = 'active' 19 | 20 | @Property({ nullable: true }) 21 | stripePriceId: string | null = null 22 | 23 | @Property({ nullable: true }) 24 | stripeCustomerId: string | null = null 25 | 26 | @Property({ nullable: true }) 27 | endDate: Date | null = null 28 | 29 | @Property() 30 | createdAt: Date = new Date() 31 | 32 | @Property({ onUpdate: () => new Date() }) 33 | updatedAt: Date = new Date() 34 | 35 | constructor(organisation: Organisation, pricingPlan: PricingPlan) { 36 | this.organisation = organisation 37 | this.pricingPlan = pricingPlan 38 | } 39 | 40 | toJSON() { 41 | return { 42 | pricingPlan: this.pricingPlan, 43 | status: this.status, 44 | endDate: this.endDate, 45 | canViewBillingPortal: Boolean(this.stripeCustomerId) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/entities/organisation.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Entity, OneToMany, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import Game from './game' 3 | import OrganisationPricingPlan from './organisation-pricing-plan' 4 | 5 | @Entity() 6 | export default class Organisation { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @Property() 11 | email!: string 12 | 13 | @Property() 14 | name!: string 15 | 16 | @OneToMany(() => Game, (game) => game.organisation, { eager: true }) 17 | games = new Collection(this) 18 | 19 | @OneToOne({ orphanRemoval: true, eager: true }) 20 | pricingPlan!: OrganisationPricingPlan 21 | 22 | @Property() 23 | createdAt: Date = new Date() 24 | 25 | @Property() 26 | updatedAt: Date = new Date() 27 | 28 | toJSON() { 29 | return { 30 | id: this.id, 31 | name: this.name, 32 | games: this.games, 33 | pricingPlan: { 34 | status: this.pricingPlan.status 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/entities/player-game-stat.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import GameStat from './game-stat' 3 | import Player from './player' 4 | 5 | @Entity() 6 | export default class PlayerGameStat { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @ManyToOne(() => Player, { deleteRule: 'cascade' }) 11 | player: Player 12 | 13 | @ManyToOne(() => GameStat, { deleteRule: 'cascade', eager: true }) 14 | stat: GameStat 15 | 16 | @Property({ type: 'double' }) 17 | value: number 18 | 19 | @Property() 20 | createdAt: Date = new Date() 21 | 22 | @Property({ onUpdate: () => new Date() }) 23 | updatedAt: Date = new Date() 24 | 25 | constructor(player: Player, stat: GameStat) { 26 | this.player = player 27 | this.stat = stat 28 | 29 | this.value = stat.defaultValue 30 | } 31 | 32 | toJSON() { 33 | return { 34 | id: this.id, 35 | stat: this.stat, 36 | value: this.value, 37 | createdAt: this.createdAt, 38 | updatedAt: this.updatedAt 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/entities/player-presence.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import Player from './player' 3 | import PlayerAlias from './player-alias' 4 | 5 | export enum PlayerPresenceStatus { 6 | ONLINE = 'online', 7 | OFFLINE = 'offline' 8 | } 9 | 10 | @Entity() 11 | export default class PlayerPresence { 12 | @PrimaryKey() 13 | id!: number 14 | 15 | @OneToOne(() => Player, (player) => player.presence) 16 | player!: Player 17 | 18 | @ManyToOne(() => PlayerAlias, { deleteRule: 'cascade', eager: true }) 19 | playerAlias!: PlayerAlias 20 | 21 | @Property() 22 | online: boolean = false 23 | 24 | @Property() 25 | customStatus: string = '' 26 | 27 | @Property() 28 | createdAt: Date = new Date() 29 | 30 | @Property({ onUpdate: () => new Date() }) 31 | updatedAt: Date = new Date() 32 | 33 | constructor(player: Player) { 34 | this.player = player 35 | } 36 | 37 | toJSON() { 38 | return { 39 | playerAlias: this.playerAlias ?? null, 40 | online: this.online, 41 | customStatus: this.customStatus, 42 | updatedAt: this.updatedAt 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/entities/player-prop.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import Player from './player' 3 | import { MAX_KEY_LENGTH, MAX_VALUE_LENGTH } from './prop' 4 | 5 | @Entity() 6 | export default class PlayerProp { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @ManyToOne(() => Player, { deleteRule: 'cascade' }) 11 | player: Player 12 | 13 | @Property({ length: MAX_KEY_LENGTH }) 14 | key: string 15 | 16 | @Property({ length: MAX_VALUE_LENGTH }) 17 | value: string 18 | 19 | @Property() 20 | createdAt: Date = new Date() 21 | 22 | constructor(player: Player, key: string, value: string) { 23 | this.player = player 24 | this.key = key 25 | this.value = value 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/entities/pricing-plan.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | 3 | @Entity() 4 | export default class PricingPlan { 5 | @PrimaryKey() 6 | id!: number 7 | 8 | @Property() 9 | stripeId!: string 10 | 11 | @Property({ default: false }) 12 | hidden!: boolean 13 | 14 | @Property({ default: false }) 15 | default!: boolean 16 | 17 | @Property({ nullable: true }) 18 | playerLimit: number | null = null 19 | 20 | @Property() 21 | createdAt: Date = new Date() 22 | 23 | @Property() 24 | updatedAt: Date = new Date() 25 | 26 | toJSON() { 27 | return { 28 | id: this.id, 29 | stripeId: this.stripeId, 30 | hidden: this.hidden, 31 | default: this.default, 32 | playerLimit: this.playerLimit 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/entities/prop.ts: -------------------------------------------------------------------------------- 1 | import { Embeddable, Property } from '@mikro-orm/mysql' 2 | 3 | export const MAX_KEY_LENGTH = 128 4 | export const MAX_VALUE_LENGTH = 512 5 | 6 | @Embeddable() 7 | export default class Prop { 8 | @Property() 9 | key: string 10 | 11 | @Property() 12 | value: string 13 | 14 | constructor(key: string, value: string) { 15 | this.key = key 16 | this.value = value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/entities/steamworks-integration-event.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import Integration from './integration' 3 | 4 | export type SteamworksRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 5 | export type SteamworksResponseStatusCode = 200 | 400 | 401 | 403 | 404 | 405 | 429 | 500 | 503 6 | 7 | export type SteamworksRequest = { 8 | url: string 9 | method: SteamworksRequestMethod 10 | body: string 11 | } 12 | 13 | export type SteamworksResponse = { 14 | status: SteamworksResponseStatusCode 15 | body: { 16 | [key: string]: unknown 17 | } 18 | timeTaken: number 19 | } 20 | 21 | @Entity() 22 | export default class SteamworksIntegrationEvent { 23 | @PrimaryKey() 24 | id!: number 25 | 26 | @ManyToOne(() => Integration) 27 | integration: Integration 28 | 29 | @Property({ type: 'json' }) 30 | request!: SteamworksRequest 31 | 32 | @Property({ type: 'json' }) 33 | response!: SteamworksResponse 34 | 35 | @Property() 36 | createdAt: Date = new Date() 37 | 38 | constructor(integration: Integration) { 39 | this.integration = integration 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/entities/steamworks-leaderboard-mapping.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, PrimaryKeyProp, Property } from '@mikro-orm/mysql' 2 | import Leaderboard from './leaderboard' 3 | 4 | @Entity() 5 | export default class SteamworksLeaderboardMapping { 6 | [PrimaryKeyProp]?: ['steamworksLeaderboardId', 'leaderboard'] 7 | 8 | @PrimaryKey() 9 | steamworksLeaderboardId: number 10 | 11 | @ManyToOne(() => Leaderboard, { primary: true, deleteRule: 'cascade', nullable: false }) 12 | leaderboard: Leaderboard 13 | 14 | @Property() 15 | createdAt: Date = new Date() 16 | 17 | constructor(steamworksLeaderboardId: number, leaderboard: Leaderboard) { 18 | this.steamworksLeaderboardId = steamworksLeaderboardId 19 | this.leaderboard = leaderboard 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/entities/subscribers/index.ts: -------------------------------------------------------------------------------- 1 | import { EventSubscriber } from '@mikro-orm/mysql' 2 | import PlayerGroupSubscriber from './player-group.subscriber' 3 | 4 | export default [ 5 | PlayerGroupSubscriber 6 | ] as EventSubscriber[] 7 | -------------------------------------------------------------------------------- /src/entities/user-access-code.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import User from './user' 3 | import generateSixDigitCode from '../lib/auth/generateSixDigitCode' 4 | 5 | @Entity() 6 | export default class UserAccessCode { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @Property() 11 | code: string = generateSixDigitCode() 12 | 13 | @ManyToOne(() => User) 14 | user: User 15 | 16 | @Property() 17 | createdAt: Date = new Date() 18 | 19 | @Property({ nullable: true }) 20 | validUntil: Date | null = null 21 | 22 | constructor(user: User, validUntil: Date | null) { 23 | this.user = user 24 | this.validUntil = validUntil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/entities/user-pinned-group.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/mysql' 2 | import User from './user' 3 | import PlayerGroup from './player-group' 4 | 5 | @Entity() 6 | @Unique({ properties: ['user', 'group'] }) 7 | export default class UserPinnedGroup { 8 | @PrimaryKey() 9 | id!: number 10 | 11 | @ManyToOne(() => User, { eager: true }) 12 | user: User 13 | 14 | @ManyToOne(() => PlayerGroup, { eager: true }) 15 | group: PlayerGroup 16 | 17 | @Property() 18 | createdAt: Date = new Date() 19 | 20 | constructor(user: User, group: PlayerGroup) { 21 | this.user = user 22 | this.group = group 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/entities/user-recovery-code.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import User from './user' 3 | import { decrypt, encrypt } from '../lib/crypto/string-encryption' 4 | 5 | @Entity() 6 | export default class UserRecoveryCode { 7 | @PrimaryKey() 8 | id!: number 9 | 10 | @ManyToOne(() => User) 11 | user: User 12 | 13 | @Property() 14 | code: string = this.generateCode() 15 | 16 | @Property() 17 | createdAt: Date = new Date() 18 | 19 | constructor(user: User) { 20 | this.user = user 21 | } 22 | 23 | generateCode(): string { 24 | const characters = 'ABCDEFGHIJKMNOPQRSTUVWXYZ0123456789' 25 | let code = '' 26 | 27 | for (let i = 0; i < 10; i++ ) { 28 | code += characters.charAt(Math.floor(Math.random() * characters.length)) 29 | } 30 | 31 | return encrypt(code, process.env.RECOVERY_CODES_SECRET!) 32 | } 33 | 34 | getPlainCode(): string { 35 | return decrypt(this.code, process.env.RECOVERY_CODES_SECRET!) 36 | } 37 | 38 | toJSON() { 39 | return this.getPlainCode() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/entities/user-session.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import { v4 } from 'uuid' 3 | import User from './user' 4 | import { add } from 'date-fns' 5 | 6 | @Entity() 7 | export default class UserSession { 8 | @PrimaryKey() 9 | id!: number 10 | 11 | @Property() 12 | token: string = v4() 13 | 14 | @Property({ nullable: true }) 15 | userAgent?: string 16 | 17 | @ManyToOne(() => User) 18 | user: User 19 | 20 | @Property() 21 | createdAt: Date = new Date() 22 | 23 | @Property() 24 | validUntil: Date = add(new Date(), { days: 7 }) 25 | 26 | constructor(user: User) { 27 | this.user = user 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/entities/user-two-factor-auth.ts: -------------------------------------------------------------------------------- 1 | import { Entity, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql' 2 | import User from './user' 3 | 4 | @Entity() 5 | export default class UserTwoFactorAuth { 6 | @PrimaryKey() 7 | id!: number 8 | 9 | @OneToOne(() => User, (user) => user.twoFactorAuth) 10 | user!: User 11 | 12 | @Property({ hidden: true }) 13 | secret: string 14 | 15 | @Property({ default: false }) 16 | enabled!: boolean 17 | 18 | constructor(secret: string) { 19 | this.secret = secret 20 | } 21 | 22 | toJSON() { 23 | return { 24 | id: this.id, 25 | enabled: this.enabled 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import 'vitest' 3 | import { EntityManager } from '@mikro-orm/mysql' 4 | import Koa from 'koa' 5 | import { ClickHouseClient } from '@clickhouse/client' 6 | import { ClayDocs } from 'koa-clay' 7 | 8 | declare global { 9 | // clay 10 | var clay: { 11 | docs: ClayDocs 12 | } 13 | // tests 14 | var app: ReturnType 15 | var ctx: ReturnType 16 | var em: EntityManager 17 | var clickhouse: ClickHouseClient 18 | } 19 | 20 | export {} 21 | -------------------------------------------------------------------------------- /src/lib/auth/generateRecoveryCodes.ts: -------------------------------------------------------------------------------- 1 | import User from '../../entities/user' 2 | import UserRecoveryCode from '../../entities/user-recovery-code' 3 | 4 | export default function generateRecoveryCodes(user: User): UserRecoveryCode[] { 5 | return [...new Array(8)].map(() => new UserRecoveryCode(user)) 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/auth/generateSixDigitCode.ts: -------------------------------------------------------------------------------- 1 | export default function generateSixDigitCode() { 2 | return Math.random().toString().substring(2, 8) 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/auth/getAPIKeyFromToken.ts: -------------------------------------------------------------------------------- 1 | import { RequestContext } from '@mikro-orm/mysql' 2 | import APIKey from '../../entities/api-key' 3 | import jwt from 'jsonwebtoken' 4 | 5 | export default async function getAPIKeyFromToken(authHeader: string): Promise { 6 | const parts = authHeader.split('Bearer ') 7 | if (parts.length === 2) { 8 | const em = RequestContext.getEntityManager()! 9 | const decodedToken = jwt.decode(parts[1]) 10 | 11 | if (decodedToken) { 12 | const apiKey = await em.getRepository(APIKey).findOne(Number(decodedToken.sub), { 13 | populate: ['game', 'game.apiSecret'] 14 | }) 15 | 16 | return apiKey 17 | } 18 | } 19 | 20 | return null 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/auth/getUserFromToken.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import { Context } from 'koa' 3 | import User from '../../entities/user' 4 | 5 | const getUserFromToken = async (ctx: Context, relations?: string[]): Promise => { 6 | // user with email = loaded entity, user with sub = jwt 7 | if (ctx.state.user.email) { 8 | const user: User = ctx.state.user 9 | await (ctx.em).getRepository(User).populate(user, relations as never[]) 10 | return user 11 | } 12 | 13 | const userId: number = ctx.state.user.sub 14 | const user = await (ctx.em).getRepository(User).findOneOrFail(userId, { populate: relations as never[] }) 15 | return user 16 | } 17 | 18 | export default getUserFromToken 19 | -------------------------------------------------------------------------------- /src/lib/auth/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | export function sign(payload: T, secret: string, options: jwt.SignOptions = {}): Promise { 4 | return new Promise((resolve, reject) => { 5 | jwt.sign(payload, secret, options, (err, token) => { 6 | if (err) reject(err) 7 | resolve(token as string) 8 | }) 9 | }) 10 | } 11 | 12 | export function verify(token: string, secret: string): Promise { 13 | return new Promise((resolve, reject) => { 14 | jwt.verify(token, secret, (err, decoded) => { 15 | if (err) reject(err) 16 | resolve(decoded as T) 17 | }) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/billing/checkPricingPlanPlayerLimit.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import { Request } from 'koa-clay' 3 | import Organisation from '../../entities/organisation' 4 | import PlanUsageWarning from '../../emails/plan-usage-warning-mail' 5 | import queueEmail from '../messaging/queueEmail' 6 | import getBillablePlayerCount from './getBillablePlayerCount' 7 | 8 | const OVERAGE_PERCENTAGE = 1.05 9 | 10 | export default async function checkPricingPlanPlayerLimit( 11 | req: Request, 12 | organisation: Organisation 13 | ): Promise { 14 | const em: EntityManager = req.ctx.em 15 | const organisationPricingPlan = organisation.pricingPlan 16 | 17 | if (organisationPricingPlan.status !== 'active') { 18 | req.ctx.throw(402, 'Your subscription is in an incomplete state. Please update your billing details.') 19 | } 20 | 21 | const planPlayerLimit = organisationPricingPlan.pricingPlan.playerLimit ?? Infinity 22 | const playerCount = await getBillablePlayerCount(em, organisation) + 1 23 | 24 | if (playerCount > (planPlayerLimit * OVERAGE_PERCENTAGE)) { 25 | req.ctx.throw(402, { limit: planPlayerLimit }) 26 | } else { 27 | const usagePercentage = playerCount / planPlayerLimit * 100 28 | if (usagePercentage == 75 || usagePercentage == 90 || usagePercentage == 100) { 29 | await queueEmail(req.ctx.emailQueue, new PlanUsageWarning(organisation, playerCount, planPlayerLimit)) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/billing/createDefaultPricingPlan.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import Organisation from '../../entities/organisation' 3 | import OrganisationPricingPlan from '../../entities/organisation-pricing-plan' 4 | import PricingPlan from '../../entities/pricing-plan' 5 | import initStripe from './initStripe' 6 | 7 | export default async function createDefaultPricingPlan(em: EntityManager, organisation: Organisation): Promise { 8 | const stripe = initStripe() 9 | 10 | let defaultPlan = await em.getRepository(PricingPlan).findOne({ default: true }) 11 | 12 | let price: string 13 | if (process.env.STRIPE_KEY && defaultPlan) { 14 | const prices = await stripe!.prices.list({ product: defaultPlan.stripeId, active: true }) 15 | price = prices.data[0].id 16 | } else { 17 | // self-hosted logic 18 | defaultPlan = new PricingPlan() 19 | defaultPlan.stripeId = '' 20 | defaultPlan.default = true 21 | } 22 | 23 | if (!organisation.pricingPlan) { 24 | const organisationPricingPlan = new OrganisationPricingPlan(organisation, defaultPlan) 25 | organisationPricingPlan.stripePriceId = price! 26 | return organisationPricingPlan 27 | } else { 28 | organisation.pricingPlan.pricingPlan = defaultPlan 29 | organisation.pricingPlan.status = 'active' 30 | organisation.pricingPlan.stripePriceId = price! 31 | organisation.pricingPlan.endDate = null 32 | return organisation.pricingPlan 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/billing/getBillablePlayerCount.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import Organisation from '../../entities/organisation' 3 | import Player from '../../entities/player' 4 | 5 | export default async function getBillablePlayerCount(em: EntityManager, organisation: Organisation): Promise { 6 | return em.getRepository(Player).count({ 7 | game: { organisation } 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/billing/initStripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | 3 | export default function initStripe(): Stripe | null { 4 | if (!process.env.STRIPE_KEY) { 5 | return null 6 | } 7 | 8 | const opts: Stripe.StripeConfig = { apiVersion: '2025-03-31.basil' } 9 | if (process.env.NODE_ENV === 'test') { 10 | opts.protocol = 'http' 11 | opts.host = 'localhost' 12 | opts.port = 12111 13 | } 14 | 15 | return new Stripe(process.env.STRIPE_KEY, opts) 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/clickhouse/clickhouse-entity.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | 3 | export default class ClickHouseEntity = [], V extends Array = []> { 4 | constructor() {} 5 | 6 | construct(..._args: U): this { 7 | throw new Error('construct must be implemented') 8 | } 9 | 10 | toInsertable(): T { 11 | throw new Error('toInsertable must be implemented') 12 | } 13 | 14 | async hydrate(_em: EntityManager, _data: T, ..._args: V): Promise { 15 | throw new Error('hydrate must be implemented') 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/clickhouse/createClient.ts: -------------------------------------------------------------------------------- 1 | import { ClickHouseClient, createClient } from '@clickhouse/client' 2 | 3 | export default function createClickHouseClient(): ClickHouseClient { 4 | return createClient({ 5 | url: `http://${process.env.CLICKHOUSE_USER}:${process.env.CLICKHOUSE_PASSWORD}@${process.env.CLICKHOUSE_HOST}:${process.env.CLICKHOUSE_PORT}/${process.env.CLICKHOUSE_DB}` 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/clickhouse/formatDateTime.ts: -------------------------------------------------------------------------------- 1 | export function formatDateForClickHouse(date: Date, includeMs = true): string { 2 | const year = date.getFullYear() 3 | const month = String(date.getMonth() + 1).padStart(2, '0') 4 | const day = String(date.getDate()).padStart(2, '0') 5 | const hours = String(date.getHours()).padStart(2, '0') 6 | const minutes = String(date.getMinutes()).padStart(2, '0') 7 | const seconds = String(date.getSeconds()).padStart(2, '0') 8 | const ms = includeMs ? `.${String(date.getMilliseconds()).padStart(3, '0')}` : '' 9 | 10 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}${ms}` 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/crypto/string-encryption.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | const IV_LENGTH = 16 4 | 5 | export function encrypt(text: string, key: string): string { 6 | const iv = Buffer.from(crypto.randomBytes(IV_LENGTH)).toString('hex').slice(0, IV_LENGTH) 7 | const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv) 8 | let encrypted = cipher.update(text) 9 | 10 | encrypted = Buffer.concat([encrypted, cipher.final()]) 11 | return iv + ':' + encrypted.toString('hex') 12 | } 13 | 14 | export function decrypt(text: string, key: string): string { 15 | const textParts: string[] = text.split(':') 16 | 17 | const iv = Buffer.from(textParts.shift()!, 'binary') 18 | const encryptedText = Buffer.from(textParts.join(':'), 'hex') 19 | const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key), iv) 20 | let decrypted = decipher.update(encryptedText) 21 | 22 | decrypted = Buffer.concat([decrypted, decipher.final()]) 23 | return decrypted.toString() 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/dates/dateValidationSchema.ts: -------------------------------------------------------------------------------- 1 | import { isBefore, isSameDay, isValid } from 'date-fns' 2 | import { Request, Validatable, ValidationCondition } from 'koa-clay' 3 | 4 | export function buildDateValidationSchema(startDateRequired: boolean, endDateRequired: boolean) { 5 | const schema: Validatable = { 6 | startDate: { 7 | required: startDateRequired, 8 | validation: async (val: unknown, req: Request): Promise => { 9 | const startDate = new Date(val as string | number) 10 | const endDate = new Date(req.ctx.query.endDate as string | number) 11 | 12 | return [ 13 | { 14 | check: startDateRequired ? isValid(startDate) : true, 15 | error: 'Invalid start date, please use YYYY-MM-DD or a timestamp', 16 | break: true 17 | }, 18 | { 19 | check: isValid(endDate) ? (isBefore(startDate, endDate) || isSameDay(startDate, endDate)) : true, 20 | error: 'Invalid start date, it should be before the end date' 21 | } 22 | ] 23 | } 24 | }, 25 | endDate: { 26 | required: endDateRequired, 27 | validation: async (val: unknown): Promise => [ 28 | { 29 | check: endDateRequired ? isValid(new Date(val as string | number)) : true, 30 | error: 'Invalid end date, please use YYYY-MM-DD or a timestamp' 31 | } 32 | ] 33 | } 34 | } 35 | return schema 36 | } 37 | 38 | const dateValidationSchema = buildDateValidationSchema(true, true) 39 | 40 | export default dateValidationSchema 41 | -------------------------------------------------------------------------------- /src/lib/dates/randomDate.ts: -------------------------------------------------------------------------------- 1 | export default function randomDate(start: Date, end: Date): Date { 2 | return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())) 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/entities/updateAllowedKeys.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'koa-clay' 2 | 3 | export default function updateAllowedKeys(entity: T, body: Request['body'], allowedKeys: (keyof T)[]): [T, string[]] { 4 | const changedProperties: string[] = [] 5 | 6 | for (const key in body) { 7 | const typedKey = key as keyof T 8 | if (allowedKeys.includes(typedKey)) { 9 | const original = entity[typedKey] 10 | entity[typedKey] = body[key] 11 | if (original !== entity[typedKey]) changedProperties.push(key) 12 | } 13 | } 14 | 15 | return [entity, changedProperties] 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/errors/buildErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'koa-clay' 2 | 3 | type ErrorResponse = { 4 | errors: Record 5 | } 6 | 7 | export default function buildErrorResponse(errors: Record): Response { 8 | return { 9 | status: 400, 10 | body: { 11 | errors 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/errors/checkRateLimitExceeded.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | 3 | export default async function checkRateLimitExceeded(redis: Redis, key: string, maxRequests: number): Promise { 4 | const redisKey = `requests.${key}` 5 | const current = await redis.get(redisKey) 6 | 7 | if (Number(current) > maxRequests) { 8 | return true 9 | } else { 10 | await redis.set(redisKey, Number(current) + 1, 'EX', 1) 11 | } 12 | 13 | return false 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/errors/handleSQLError.ts: -------------------------------------------------------------------------------- 1 | import { camelCase } from 'lodash' 2 | import buildErrorResponse from './buildErrorResponse' 3 | 4 | export default function handleSQLError(err: Error) { 5 | if ('sqlState' in err && err.sqlState === '22003') { 6 | const regex = /Out of range value for column '(\w+)' at row 1/ 7 | const match = err.message.match(regex)! 8 | return buildErrorResponse({ 9 | [camelCase(match[1])]: ['Value is out of range'] 10 | }) 11 | } else if ('sqlState' in err && err.sqlState === '22001') { 12 | const regex = /Data too long for column '(\w+)' at row 1/ 13 | const match = err.message.match(regex)! 14 | return buildErrorResponse({ 15 | [camelCase(match[1])]: [`${match[1]} is too long`] 16 | }) 17 | /* v8 ignore start */ 18 | } else { 19 | throw err 20 | } 21 | /* v8 ignore stop */ 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/errors/propSizeError.ts: -------------------------------------------------------------------------------- 1 | export class PropSizeError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = 'PropSizeError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/groups/checkGroupMemberships.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import Player from '../../entities/player' 3 | import PlayerGroup from '../../entities/player-group' 4 | 5 | export default async function checkGroupMemberships(em: EntityManager, player: Player) { 6 | const groups = await em.getRepository(PlayerGroup).find({ game: player.game }) 7 | 8 | for (const group of groups) { 9 | await group.members.loadItems() 10 | 11 | const eligiblePlayers = await group.getQuery(em).getResultList() 12 | const playerIsEligible = eligiblePlayers.some((eligiblePlayer) => eligiblePlayer.id === player.id) 13 | const playerCurrentlyInGroup = group.members.getItems().some((p) => p.id === player.id) 14 | 15 | if (playerIsEligible && !playerCurrentlyInGroup) { 16 | group.members.add(player) 17 | } else if (!playerIsEligible && playerCurrentlyInGroup) { 18 | const member = group.members.getItems().find((member) => member.id === player.id) 19 | if (member) { 20 | group.members.remove(member) 21 | } 22 | } 23 | } 24 | 25 | await em.flush() 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/integrations/triggerIntegrations.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import Game from '../../entities/game' 3 | import Integration from '../../entities/integration' 4 | 5 | export default async function triggerIntegrations(em: EntityManager, game: Game, callback: (integration: Integration) => void) { 6 | const integrations = await em.getRepository(Integration).find({ game }) 7 | await Promise.all(integrations.map((integration) => callback(integration))) 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/lang/emailRegex.ts: -------------------------------------------------------------------------------- 1 | export default new RegExp(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/) 2 | -------------------------------------------------------------------------------- /src/lib/lang/upperFirst.ts: -------------------------------------------------------------------------------- 1 | export default function upperFirst(str: string): string { 2 | return str.substring(0, 1).toUpperCase() + str.substring(1) 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/logging/createGameActivity.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import GameActivity from '../../entities/game-activity' 3 | import Game from '../../entities/game' 4 | 5 | type GameActivityData = Pick & { game?: Game, extra?: Record } 6 | 7 | export default function createGameActivity(em: EntityManager, data: GameActivityData): GameActivity { 8 | const activity = new GameActivity(data.game ?? null, data.user) 9 | activity.type = data.type 10 | activity.extra = data.extra ?? {} 11 | 12 | em.persist(activity) 13 | 14 | return activity 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/logging/createPlayerAuthActivity.ts: -------------------------------------------------------------------------------- 1 | import PlayerAuthActivity, { PlayerAuthActivityType } from '../../entities/player-auth-activity' 2 | import Player from '../../entities/player' 3 | import { Request } from 'koa-clay' 4 | import { EntityManager } from '@mikro-orm/mysql' 5 | 6 | export default function createPlayerAuthActivity( 7 | req: Request, 8 | player: Player, 9 | data: { type: PlayerAuthActivityType, extra?: Record } 10 | ): PlayerAuthActivity { 11 | const em: EntityManager = req.ctx.em 12 | 13 | const activity = new PlayerAuthActivity(player) 14 | activity.type = data.type 15 | activity.extra = { 16 | ...(data.extra ?? {}), 17 | userAgent: req.headers['user-agent'], 18 | ip: req.ctx.request.ip 19 | } 20 | 21 | em.persist(activity) 22 | 23 | return activity 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/messaging/queueEmail.ts: -------------------------------------------------------------------------------- 1 | import { Job, Queue } from 'bullmq' 2 | import Mail from '../../emails/mail' 3 | import { EmailConfig, EmailConfigMetadata } from './sendEmail' 4 | 5 | export default async (emailQueue: Queue, mail: Mail, metadata?: EmailConfigMetadata): Promise> => { 6 | const job = await emailQueue.add('new-email', { 7 | mail: mail.getConfig(), 8 | metadata 9 | }) 10 | 11 | return job 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/queues/createEmailQueue.ts: -------------------------------------------------------------------------------- 1 | import createQueue, { WorkerEvents } from './createQueue' 2 | import sendEmail, { EmailConfig } from '../messaging/sendEmail' 3 | 4 | const createEmailQueue = (events: WorkerEvents = {}, prefix = '') => { 5 | const queue = createQueue(prefix + 'email', async (job) => { 6 | await sendEmail(job.data.mail) 7 | }, events) 8 | 9 | return queue 10 | } 11 | 12 | export default createEmailQueue 13 | -------------------------------------------------------------------------------- /src/lib/queues/createQueue.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions, Job, Processor, Queue, RedisOptions, Worker } from 'bullmq' 2 | import redisConfig from '../../config/redis.config' 3 | import handleJobFailure from './handleJobFailure' 4 | 5 | export type WorkerEvents = { 6 | failed?: (job: Job, err: Error) => void | Promise 7 | completed?: (job: Job) => void | Promise 8 | } 9 | 10 | function createQueue(name: string, processor: Processor, events: WorkerEvents = {}): Queue { 11 | const connection: ConnectionOptions = redisConfig as RedisOptions 12 | const queue = new Queue(name, { connection }) 13 | const worker = new Worker(queue.name, processor, { connection }) 14 | 15 | worker.on('failed', async (job, err) => { 16 | if (job) { 17 | await events.failed?.(job, err) 18 | await handleJobFailure(job, err) 19 | } 20 | }) 21 | 22 | worker.on('completed', async (job: Job) => { 23 | await events.completed?.(job) 24 | }) 25 | 26 | return queue 27 | } 28 | 29 | export default createQueue 30 | -------------------------------------------------------------------------------- /src/lib/queues/handleJobFailure.ts: -------------------------------------------------------------------------------- 1 | import { MikroORM } from '@mikro-orm/mysql' 2 | import ormConfig from '../../config/mikro-orm.config' 3 | import FailedJob from '../../entities/failed-job' 4 | import * as Sentry from '@sentry/node' 5 | import { Job } from 'bullmq' 6 | 7 | async function handleJobFailure(job: Job, err: Error): Promise { 8 | const orm = await MikroORM.init(ormConfig) 9 | const em = orm.em.fork() 10 | 11 | const failedJob = new FailedJob() 12 | failedJob.payload = job.data as unknown as (typeof failedJob.payload) 13 | failedJob.queue = job.queueName 14 | failedJob.reason = err.message 15 | /* v8 ignore next */ 16 | failedJob.stack = err.stack ?? '' 17 | 18 | await em.persistAndFlush(failedJob) 19 | await orm.close() 20 | 21 | Sentry.setContext('queue', { 22 | 'Name': job.queueName, 23 | 'Failed Job ID': failedJob.id 24 | }) 25 | Sentry.captureException(err) 26 | } 27 | 28 | export default handleJobFailure 29 | -------------------------------------------------------------------------------- /src/lib/users/setUserLastSeenAt.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import jwt from 'jsonwebtoken' 3 | import User from '../../entities/user' 4 | import { differenceInDays } from 'date-fns' 5 | import { Request, Response } from 'koa-clay' 6 | 7 | export default async (req: Request, res: Response<{ accessToken: string }>): Promise => { 8 | const em: EntityManager = req.ctx.em 9 | const token: string = res.body?.accessToken ?? '' 10 | 11 | if (token) { 12 | const user = await em.getRepository(User).findOneOrFail(Number(jwt.decode(token)?.sub)) 13 | if (differenceInDays(new Date(), user.lastSeenAt) >= 1) { 14 | user.lastSeenAt = new Date() 15 | await em.flush() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/middlewares/api-key-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import { isAPIRoute } from './route-middleware' 3 | import getAPIKeyFromToken from '../lib/auth/getAPIKeyFromToken' 4 | import { EntityManager } from '@mikro-orm/mysql' 5 | 6 | export default async function apiKeyMiddleware(ctx: Context, next: Next): Promise { 7 | if (isAPIRoute(ctx)) { 8 | const apiKey = await getAPIKeyFromToken(ctx.headers?.authorization ?? '') 9 | if (apiKey) { 10 | ctx.state.key = apiKey 11 | ctx.state.secret = apiKey.game.apiSecret.getPlainSecret() 12 | ctx.state.game = apiKey.game 13 | 14 | if (!apiKey.revokedAt) apiKey.lastUsedAt = new Date() 15 | await (ctx.em).flush() 16 | } 17 | } 18 | 19 | await next() 20 | } 21 | -------------------------------------------------------------------------------- /src/middlewares/cleanup-middleware.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { Context, Next } from 'koa' 3 | 4 | export default async function cleanupMiddleware(ctx: Context, next: Next): Promise { 5 | if (ctx.state.redis instanceof Redis) { 6 | await (ctx.state.redis as Redis).quit() 7 | } 8 | 9 | await next() 10 | } 11 | -------------------------------------------------------------------------------- /src/middlewares/continunity-middleware.ts: -------------------------------------------------------------------------------- 1 | import { isValid } from 'date-fns' 2 | import { Context, Next } from 'koa' 3 | import { APIKeyScope } from '../entities/api-key' 4 | import { isAPIRoute } from './route-middleware' 5 | import checkScope from '../policies/checkScope' 6 | 7 | export default async function continuityMiddleware(ctx: Context, next: Next): Promise { 8 | if (isAPIRoute(ctx) && checkScope(ctx.state.key, APIKeyScope.WRITE_CONTINUITY_REQUESTS)) { 9 | const header = ctx.headers['x-talo-continuity-timestamp'] 10 | 11 | if (header) { 12 | const date = new Date(Number(header)) 13 | if (isValid(date)) { 14 | ctx.state.continuityDate = date 15 | } 16 | } 17 | } 18 | 19 | await next() 20 | } 21 | -------------------------------------------------------------------------------- /src/middlewares/cors-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import cors from '@koa/cors' 3 | import { isAPIRoute } from './route-middleware' 4 | 5 | export default async function corsMiddleware(ctx: Context, next: Next): Promise { 6 | if (isAPIRoute(ctx)) { 7 | return cors()(ctx, next) 8 | } else { 9 | return cors({ 10 | credentials: true, 11 | origin: process.env.DASHBOARD_URL 12 | })(ctx, next) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/middlewares/current-player-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | 3 | export function setCurrentPlayerState(ctx: Context, playerId: string, aliasId: number | undefined) { 4 | ctx.state.currentPlayerId = playerId 5 | ctx.state.currentAliasId = aliasId 6 | } 7 | 8 | export default async function currentPlayerMiddleware(ctx: Context, next: Next): Promise { 9 | setCurrentPlayerState( 10 | ctx, ctx.headers['x-talo-player'] as string, 11 | ctx.headers['x-talo-alias'] ? Number(ctx.headers['x-talo-alias']) : undefined 12 | ) 13 | 14 | await next() 15 | } 16 | -------------------------------------------------------------------------------- /src/middlewares/dev-data-middleware.ts: -------------------------------------------------------------------------------- 1 | import { QBFilterQuery, EntityManager } from '@mikro-orm/mysql' 2 | import { Context, Next } from 'koa' 3 | import Player from '../entities/player' 4 | import PlayerProp from '../entities/player-prop' 5 | 6 | export default async function devDataMiddleware(ctx: Context, next: Next): Promise { 7 | if (Number(ctx.headers['x-talo-include-dev-data'])) { 8 | ctx.state.includeDevData = true 9 | } 10 | 11 | await next() 12 | } 13 | 14 | export function devDataPlayerFilter(em: EntityManager): QBFilterQuery { 15 | return { 16 | $nin: em.qb(PlayerProp).select('player_id', true).where({ 17 | key: 'META_DEV_BUILD' 18 | }).getKnexQuery() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/middlewares/error-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import * as Sentry from '@sentry/node' 3 | import Redis from 'ioredis' 4 | 5 | export default async function errorMiddleware(ctx: Context, next: Next) { 6 | try { 7 | await next() 8 | } catch (err) { 9 | if (err instanceof Error) { 10 | ctx.status = 'status' in err ? err.status as number : 500 11 | ctx.body = ctx.status === 401 && Boolean('originalError' in err && err.originalError) /* dont expose jwt error */ 12 | ? { message: 'Please provide a valid token in the Authorization header' } 13 | : { ...err, headers: undefined /* koa cors is inserting headers into the body for some reason */ } 14 | 15 | if (ctx.state.redis instanceof Redis) { 16 | await (ctx.state.redis as Redis).quit() 17 | } 18 | 19 | if (ctx.status === 500) { 20 | Sentry.withScope((scope) => { 21 | scope.addEventProcessor((event) => { 22 | return Sentry.addRequestDataToEvent(event, ctx.request as Sentry.PolymorphicRequest) 23 | }) 24 | 25 | if (ctx.state.user) { 26 | Sentry.setUser({ id: ctx.state.user.id, username: ctx.state.user.username }) 27 | Sentry.setTag('apiKey', ctx.state.user.api ?? false) 28 | } 29 | 30 | Sentry.captureException(err) 31 | }) 32 | 33 | if (process.env.NODE_ENV !== 'production') { 34 | console.error(err.stack) 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/middlewares/limiter-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import { createRedisConnection } from '../config/redis.config' 3 | import { isAPIRoute } from './route-middleware' 4 | import checkRateLimitExceeded from '../lib/errors/checkRateLimitExceeded' 5 | 6 | const MAX_REQUESTS = 50 7 | 8 | export default async function limiterMiddleware(ctx: Context, next: Next): Promise { 9 | if (isAPIRoute(ctx) && process.env.NODE_ENV !== 'test') { 10 | const key = ctx.state.user.sub 11 | const redis = createRedisConnection(ctx) 12 | 13 | if (await checkRateLimitExceeded(redis, key, MAX_REQUESTS)) { 14 | ctx.throw(429) 15 | } 16 | } 17 | 18 | await next() 19 | } 20 | -------------------------------------------------------------------------------- /src/middlewares/request-context-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import { RequestContext } from '@mikro-orm/mysql' 3 | 4 | export default async function requestContextMiddleware(ctx: Context, next: Next): Promise { 5 | return RequestContext.create(ctx.em, next) 6 | } 7 | -------------------------------------------------------------------------------- /src/middlewares/route-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import jwt from 'koa-jwt' 3 | 4 | function isPublicRoute(ctx: Context): boolean { 5 | return ctx.path.match(/^\/(public)\//) !== null 6 | } 7 | 8 | export function isAPIRoute(ctx: Context): boolean { 9 | return ctx.path.match(/^\/(v1)\//) !== null 10 | } 11 | 12 | function isProtectedRoute(ctx: Context): boolean { 13 | return !isPublicRoute(ctx) && !isAPIRoute(ctx) 14 | } 15 | 16 | function isAPICall(ctx: Context): boolean { 17 | return ctx.state.user?.api === true 18 | } 19 | 20 | type RouteInfo = { 21 | isPublicRoute: boolean 22 | isProtectedRoute: boolean 23 | isAPIRoute: boolean 24 | isAPICall: boolean 25 | } 26 | 27 | export function getRouteInfo(ctx: Context): RouteInfo { 28 | return { 29 | isPublicRoute: isPublicRoute(ctx), 30 | isProtectedRoute: isProtectedRoute(ctx), 31 | isAPIRoute: isAPIRoute(ctx), 32 | isAPICall: isAPICall(ctx) 33 | } 34 | } 35 | 36 | export async function protectedRouteAuthMiddleware(ctx: Context, next: Next): Promise { 37 | if (isProtectedRoute(ctx)) { 38 | return jwt({ secret: process.env.JWT_SECRET! })(ctx, next) 39 | } else { 40 | await next() 41 | } 42 | } 43 | 44 | export async function apiRouteAuthMiddleware(ctx: Context, next: Next): Promise { 45 | if (isAPIRoute(ctx)) { 46 | return jwt({ 47 | secret: ctx.state.secret, 48 | isRevoked: async (ctx): Promise => { 49 | return ctx.state.key.revokedAt !== null 50 | } 51 | })(ctx, next) 52 | } else { 53 | await next() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/middlewares/tracing-middleware.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node' 2 | import { Context, Next } from 'koa' 3 | import { stripUrlQueryAndFragment } from '@sentry/utils' 4 | 5 | export default async function tracingMiddleware(ctx: Context, next: Next) { 6 | const reqMethod = ctx.method.toUpperCase() 7 | const reqUrl = stripUrlQueryAndFragment(ctx.url) 8 | 9 | // connect to trace of upstream app 10 | let traceparentData 11 | /* v8 ignore next 3 */ 12 | if (ctx.request.get('sentry-trace')) { 13 | traceparentData = Sentry.extractTraceparentData(ctx.request.get('sentry-trace')) 14 | } 15 | 16 | const transaction = Sentry.startTransaction({ 17 | name: `${reqMethod} ${reqUrl}`, 18 | op: 'http.server', 19 | ...traceparentData 20 | }) 21 | 22 | ctx.__sentry_transaction = transaction 23 | 24 | // we put the transaction on the scope so users can attach children to it 25 | Sentry.getCurrentHub().configureScope((scope) => { 26 | scope.setSpan(transaction) 27 | }) 28 | 29 | ctx.res.on('finish', () => { 30 | // push `transaction.finish` to the next event loop so open spans have a chance to finish before the transaction closes 31 | setImmediate(() => { 32 | if (ctx.state.matchedRoute) { 33 | transaction.setName(`${reqMethod} ${ctx.state.matchedRoute}`) 34 | } 35 | transaction.setHttpStatus(ctx.status) 36 | transaction.finish() 37 | }) 38 | }) 39 | 40 | await next() 41 | } 42 | -------------------------------------------------------------------------------- /src/migrations/20210926160859CreateDataExportsTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateDataExportsTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `data_export` (`id` int unsigned not null auto_increment primary key, `created_by_user_id` int(11) unsigned not null, `game_id` int(11) unsigned not null, `entities` text not null, `status` tinyint not null, `failed_at` datetime null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `data_export` add index `data_export_created_by_user_id_index`(`created_by_user_id`);') 8 | this.addSql('alter table `data_export` add index `data_export_game_id_index`(`game_id`);') 9 | 10 | this.addSql('alter table `data_export` add constraint `data_export_created_by_user_id_foreign` foreign key (`created_by_user_id`) references `user` (`id`) on update cascade;') 11 | this.addSql('alter table `data_export` add constraint `data_export_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade;') 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/migrations/20211205171927CreateUserTwoFactorAuthTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateUserTwoFactorAuthTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `user_two_factor_auth` (`id` int unsigned not null auto_increment primary key, `secret` varchar(255) not null, `enabled` tinyint(1) not null) default character set utf8mb4 engine = InnoDB;') 7 | 8 | this.addSql('alter table `user` add `two_factor_auth_id` int(11) unsigned null;') 9 | this.addSql('alter table `user` add index `user_two_factor_auth_id_index`(`two_factor_auth_id`);') 10 | this.addSql('alter table `user` add unique `user_two_factor_auth_id_unique`(`two_factor_auth_id`);') 11 | 12 | this.addSql('alter table `user` add constraint `user_two_factor_auth_id_foreign` foreign key (`two_factor_auth_id`) references `user_two_factor_auth` (`id`) on update cascade on delete set null;') 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/migrations/20211209003017CreateUserRecoveryCodeTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateUserRecoveryCodeTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `user_recovery_code` (`id` int unsigned not null auto_increment primary key, `user_id` int(11) unsigned not null, `code` varchar(255) not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `user_recovery_code` add index `user_recovery_code_user_id_index`(`user_id`);') 8 | 9 | this.addSql('alter table `user_recovery_code` add constraint `user_recovery_code_user_id_foreign` foreign key (`user_id`) references `user` (`id`) on delete cascade;') 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/migrations/20211224154919AddLeaderboardEntryHiddenColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddLeaderboardEntryHiddenColumn extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `leaderboard_entry` add `hidden` tinyint(1) not null default false;') 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/migrations/20220109144435CreateGameSavesTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateGameSavesTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `game_save` (`id` int unsigned not null auto_increment primary key, `name` varchar(255) not null, `content` json not null, `player_id` varchar(255) null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `game_save` add index `game_save_player_id_index`(`player_id`);') 8 | 9 | this.addSql('alter table `game_save` add constraint `game_save_player_id_foreign` foreign key (`player_id`) references `player` (`id`) on delete cascade;') 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/migrations/20220125220401CreateGameActivitiesTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateGameActivitiesTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `game_activity` (`id` int unsigned not null auto_increment primary key, `game_id` int(11) unsigned not null, `user_id` int(11) unsigned not null, `type` tinyint not null, `extra` json not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `game_activity` add index `game_activity_game_id_index`(`game_id`);') 8 | this.addSql('alter table `game_activity` add index `game_activity_user_id_index`(`user_id`);') 9 | 10 | this.addSql('alter table `game_activity` add constraint `game_activity_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade;') 11 | this.addSql('alter table `game_activity` add constraint `game_activity_user_id_foreign` foreign key (`user_id`) references `user` (`id`) on update cascade;') 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/migrations/20220203130919SetUserTwoFactorAuthEnabledDefaultFalse.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class SetUserTwoFactorAuthEnabledDefaultFalse extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `user_two_factor_auth` modify `enabled` tinyint(1) default false;') 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/migrations/20220402004932AddUsernameColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddUsernameColumn extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `user` add `username` varchar(255) not null;') 7 | 8 | this.addSql('alter table `data_export` modify `entities` text not null;') 9 | } 10 | 11 | async down(): Promise { 12 | this.addSql('alter table `user` drop `username`;') 13 | 14 | this.addSql('alter table `data_export` modify `entities` text not null;') 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/migrations/20220420141136CreateInvitesTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateInvitesTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `invite` (`id` int unsigned not null auto_increment primary key, `token` varchar(255) not null, `email` varchar(255) not null, `type` tinyint not null, `organisation_id` int unsigned not null, `invited_by_user_id` int unsigned not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `invite` add index `invite_organisation_id_index`(`organisation_id`);') 8 | this.addSql('alter table `invite` add index `invite_invited_by_user_id_index`(`invited_by_user_id`);') 9 | 10 | this.addSql('alter table `invite` add constraint `invite_organisation_id_foreign` foreign key (`organisation_id`) references `organisation` (`id`) on update cascade;') 11 | this.addSql('alter table `invite` add constraint `invite_invited_by_user_id_foreign` foreign key (`invited_by_user_id`) references `user` (`id`) on update cascade;') 12 | } 13 | 14 | async down(): Promise { 15 | this.addSql('drop table if exists `invite`;') 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/migrations/20220505190243MakeGameActivityUserNullable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class MakeGameActivityUserNullable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `game_activity` drop foreign key `game_activity_game_id_foreign`;') 7 | 8 | this.addSql('alter table `user` drop index `user_two_factor_auth_id_index`;') 9 | 10 | this.addSql('alter table `game_activity` modify `game_id` int unsigned null;') 11 | this.addSql('alter table `game_activity` add constraint `game_activity_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade on delete set null;') 12 | } 13 | 14 | async down(): Promise { 15 | this.addSql('alter table `game_activity` drop foreign key `game_activity_game_id_foreign`;') 16 | 17 | this.addSql('alter table `user` add index `user_two_factor_auth_id_index`(`two_factor_auth_id`);') 18 | 19 | this.addSql('alter table `game_activity` modify `game_id` int unsigned not null;') 20 | this.addSql('alter table `game_activity` add constraint `game_activity_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade;') 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/migrations/20220717215205CreateIntegrationsTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateIntegrationsTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `integration` (`id` int unsigned not null auto_increment primary key, `type` enum(\'steamworks\') not null, `game_id` int unsigned not null, `config` json not null, `deleted_at` datetime null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `integration` add index `integration_game_id_index`(`game_id`);') 8 | 9 | this.addSql('alter table `integration` add constraint `integration_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade;') 10 | } 11 | 12 | async down(): Promise { 13 | this.addSql('drop table if exists `integration`;') 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/migrations/20220730134520PlayerAliasServiceUseEnum.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class PlayerAliasServiceUseEnum extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `player_alias` modify `service` enum(\'steam\', \'epic\', \'username\', \'email\', \'custom\') not null;') 7 | } 8 | 9 | async down(): Promise { 10 | this.addSql('alter table `player_alias` modify `service` varchar(255) not null;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20220910200720CreatePlayerPropsTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreatePlayerPropsTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `player_prop` (`id` int unsigned not null auto_increment primary key, `player_id` varchar(255) not null, `key` varchar(255) not null, `value` varchar(255) not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `player_prop` add index `player_prop_player_id_index`(`player_id`);') 8 | 9 | this.addSql('alter table `player_prop` add constraint `player_prop_player_id_foreign` foreign key (`player_id`) references `player` (`id`) on update cascade;') 10 | 11 | this.addSql('insert into `player_prop` (`player_id`, `key`, `value`) select `p`.`id`, `r`.* from `player` as `p`, json_table(`p`.`props`, \'$[*]\' columns (`key` varchar(255) path \'$.key\', `value` varchar(255) path \'$.value\')) as `r`') 12 | 13 | this.addSql('alter table `player` drop `props`;') 14 | } 15 | 16 | async down(): Promise { 17 | this.addSql('drop table if exists `player_prop`;') 18 | 19 | this.addSql('alter table `player` add `props` json not null;') 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/migrations/20221113222058AddFailedJobStackColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddFailedJobStackColumn extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `failed_job` add `stack` text not null;') 7 | } 8 | 9 | async down(): Promise { 10 | this.addSql('alter table `failed_job` drop `stack`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20221113223142DropSteamworksLeaderboardMappingUnique.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class DropSteamworksLeaderboardMappingUnique extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `steamworks_leaderboard_mapping` drop foreign key `steamworks_leaderboard_mapping_leaderboard_id_foreign`;') 7 | this.addSql('alter table `steamworks_leaderboard_mapping` drop index `steamworks_leaderboard_mapping_leaderboard_id_unique`;') 8 | this.addSql('alter table `steamworks_leaderboard_mapping` add index `steamworks_leaderboard_mapping_leaderboard_id_index`(`leaderboard_id`);') 9 | this.addSql('alter table `steamworks_leaderboard_mapping` add constraint `steamworks_leaderboard_mapping_leaderboard_id_foreign` foreign key (`leaderboard_id`) references `leaderboard` (`id`) on delete cascade;') 10 | } 11 | 12 | async down(): Promise { 13 | this.addSql('alter table `steamworks_leaderboard_mapping` drop foreign key `steamworks_leaderboard_mapping_leaderboard_id_foreign`;') 14 | this.addSql('alter table `steamworks_leaderboard_mapping` drop index `steamworks_leaderboard_mapping_leaderboard_id_index`;') 15 | this.addSql('alter table `steamworks_leaderboard_mapping` add unique `steamworks_leaderboard_mapping_leaderboard_id_unique`(`leaderboard_id`);') 16 | this.addSql('alter table `steamworks_leaderboard_mapping` add constraint `steamworks_leaderboard_mapping_leaderboard_id_foreign` foreign key (`leaderboard_id`) references `leaderboard` (`id`) on delete cascade;') 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/migrations/20230205220924CreateGameSecretsTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateGameSecretsTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `game_secret` (`id` int unsigned not null auto_increment primary key, `secret` varchar(255) not null) default character set utf8mb4 engine = InnoDB;') 7 | 8 | this.addSql('alter table `game` add `api_secret_id` int unsigned not null;') 9 | this.addSql('alter table `game` add constraint `game_api_secret_id_foreign` foreign key (`api_secret_id`) references `game_secret` (`id`) on update cascade;') 10 | this.addSql('alter table `game` add unique `game_api_secret_id_unique`(`api_secret_id`);') 11 | } 12 | 13 | async down(): Promise { 14 | this.addSql('alter table `game` drop foreign key `game_api_secret_id_foreign`;') 15 | 16 | this.addSql('drop table if exists `game_secret`;') 17 | 18 | this.addSql('alter table `game` drop index `game_api_secret_id_unique`;') 19 | this.addSql('alter table `game` drop `api_secret_id`;') 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/migrations/20230205220925AddAPIKeyLastUsedAtColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddAPIKeyLastUsedAtColumn extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `apikey` add `last_used_at` datetime null;') 7 | } 8 | 9 | async down(): Promise { 10 | this.addSql('alter table `apikey` drop `last_used_at`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20240614122547AddAPIKeyUpdatedAtColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddAPIKeyUpdatedAtColumn extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('alter table `apikey` add `updated_at` datetime null;') 7 | } 8 | 9 | async down(): Promise { 10 | this.addSql('alter table `apikey` drop column `updated_at`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20240628155142CreatePlayerAuthTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreatePlayerAuthTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `player_auth` (`id` int unsigned not null auto_increment primary key, `password` varchar(255) not null, `email` varchar(255) null, `verification_enabled` tinyint(1) not null default false, `session_key` varchar(255) null, `session_created_at` datetime null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | 8 | this.addSql('alter table `player` add `auth_id` int unsigned null;') 9 | this.addSql('alter table `player` add constraint `player_auth_id_foreign` foreign key (`auth_id`) references `player_auth` (`id`) on update cascade on delete set null;') 10 | this.addSql('alter table `player` add unique `player_auth_id_unique`(`auth_id`);') 11 | 12 | this.addSql('alter table `player_alias` modify `service` enum(\'steam\', \'epic\', \'username\', \'email\', \'custom\', \'talo\') not null;') 13 | } 14 | 15 | async down(): Promise { 16 | this.addSql('alter table `player` drop foreign key `player_auth_id_foreign`;') 17 | 18 | this.addSql('drop table if exists `player_auth`;') 19 | 20 | this.addSql('alter table `player` drop index `player_auth_id_unique`;') 21 | this.addSql('alter table `player` drop column `auth_id`;') 22 | 23 | this.addSql('alter table `player_alias` modify `service` enum(\'steam\', \'epic\', \'username\', \'email\', \'custom\') not null;') 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/migrations/20240725183402CreatePlayerAuthActivityTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreatePlayerAuthActivityTable extends Migration { 4 | 5 | async up(): Promise { 6 | this.addSql('create table `player_auth_activity` (`id` int unsigned not null auto_increment primary key, `player_id` varchar(255) not null, `type` tinyint not null, `extra` json not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `player_auth_activity` add index `player_auth_activity_player_id_index`(`player_id`);') 8 | 9 | this.addSql('alter table `player_auth_activity` add constraint `player_auth_activity_player_id_foreign` foreign key (`player_id`) references `player` (`id`) on update cascade;') 10 | } 11 | 12 | async down(): Promise { 13 | this.addSql('drop table if exists `player_auth_activity`;') 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/migrations/20240916213402UpdatePlayerAliasServiceColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class UpdatePlayerAliasServiceColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_alias` modify `service` varchar(255) not null;') 7 | 8 | this.addSql('alter table `data_export` modify `entities` text not null;') 9 | 10 | this.addSql('alter table `apikey` modify `scopes` text not null;') 11 | } 12 | 13 | override async down(): Promise { 14 | this.addSql('alter table `player_alias` modify `service` enum(\'steam\', \'epic\', \'username\', \'email\', \'custom\', \'talo\') not null;') 15 | 16 | this.addSql('alter table `data_export` modify `entities` text not null;') 17 | 18 | this.addSql('alter table `apikey` modify `scopes` text not null;') 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/migrations/20240920121232AddPlayerAliasAnonymisedColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddPlayerAliasAnonymisedColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_alias` add `anonymised` tinyint(1) not null default false;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `player_alias` drop column `anonymised`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20240922222426AddLeaderboardEntryPropsColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddLeaderboardEntryPropsColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `leaderboard_entry` add `props` json not null;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `leaderboard_entry` drop column `props`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20241001194252CreateUserPinnedGroupsTable.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CreateUserPinnedGroupsTable extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('create table `user_pinned_group` (`id` int unsigned not null auto_increment primary key, `user_id` int unsigned not null, `group_id` varchar(255) not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') 7 | this.addSql('alter table `user_pinned_group` add index `user_pinned_group_user_id_index`(`user_id`);') 8 | this.addSql('alter table `user_pinned_group` add index `user_pinned_group_group_id_index`(`group_id`);') 9 | this.addSql('alter table `user_pinned_group` add unique `user_pinned_group_user_id_group_id_unique`(`user_id`, `group_id`);') 10 | 11 | this.addSql('alter table `user_pinned_group` add constraint `user_pinned_group_user_id_foreign` foreign key (`user_id`) references `user` (`id`) on update cascade;') 12 | this.addSql('alter table `user_pinned_group` add constraint `user_pinned_group_group_id_foreign` foreign key (`group_id`) references `player_group` (`id`) on update cascade;') 13 | } 14 | 15 | override async down(): Promise { 16 | this.addSql('drop table if exists `user_pinned_group`;') 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/migrations/20241014202844AddPlayerGroupMembersVisibleColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddPlayerGroupMembersVisibleColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_group` add `members_visible` tinyint(1) not null default false;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `player_group` drop column `members_visible`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20241101233908AddPlayerPropCreatedAtColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddPlayerPropCreatedAtColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_prop` add `created_at` datetime not null default CURRENT_TIMESTAMP;') 7 | this.addSql('update `player_prop` set `created_at` = (select `updated_at` from `player` where `player`.`id` = `player_prop`.`player_id`);') 8 | 9 | this.addSql('alter table `apikey` modify `scopes` text not null;') 10 | } 11 | 12 | override async down(): Promise { 13 | this.addSql('alter table `player_prop` drop column `created_at`;') 14 | 15 | this.addSql('alter table `apikey` modify `scopes` text not null;') 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/migrations/20241102004938AddPlayerAliasLastSeenAtColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddPlayerAliasLastSeenAtColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_alias` add `last_seen_at` datetime not null default CURRENT_TIMESTAMP;') 7 | this.addSql('update `player_alias` set `last_seen_at` = `updated_at`;') 8 | } 9 | 10 | override async down(): Promise { 11 | this.addSql('alter table `player_alias` drop column `last_seen_at`;') 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/migrations/20241221210019IncreasePlayerAliasIdentifierLength.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class IncreasePlayerAliasIdentifierLength extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_alias` modify `identifier` varchar(1024) not null;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `player_alias` modify `identifier` varchar(255) not null;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20250212031914AddLeaderboardRefreshIntervalAndEntryDeletedAt.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddLeaderboardRefreshIntervalAndEntryDeletedAt extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `leaderboard` add `refresh_interval` enum(\'never\', \'daily\', \'weekly\', \'monthly\', \'yearly\') not null default \'never\';') 7 | 8 | this.addSql('alter table `leaderboard_entry` add `deleted_at` datetime null;') 9 | } 10 | 11 | override async down(): Promise { 12 | this.addSql('alter table `leaderboard` drop column `refresh_interval`;') 13 | 14 | this.addSql('alter table `leaderboard_entry` drop column `deleted_at`;') 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/migrations/20250217004535DeletePlayerAliasAnonymisedColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class DeletePlayerAliasAnonymisedColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_alias` drop foreign key `player_alias_player_id_foreign`;') 7 | 8 | this.addSql('alter table `player_alias` drop column `anonymised`;') 9 | 10 | this.addSql('alter table `player_alias` modify `player_id` varchar(255) not null;') 11 | this.addSql('alter table `player_alias` add constraint `player_alias_player_id_foreign` foreign key (`player_id`) references `player` (`id`) on update cascade;') 12 | } 13 | 14 | override async down(): Promise { 15 | this.addSql('alter table `player_alias` drop foreign key `player_alias_player_id_foreign`;') 16 | 17 | this.addSql('alter table `player_alias` add `anonymised` tinyint(1) not null default false;') 18 | this.addSql('alter table `player_alias` modify `player_id` varchar(255) null;') 19 | this.addSql('alter table `player_alias` add constraint `player_alias_player_id_foreign` foreign key (`player_id`) references `player` (`id`) on delete cascade;') 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/migrations/20250219233504CascadePlayerPresenceAlias.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class CascadePlayerPresenceAlias extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_presence` drop foreign key `player_presence_player_alias_id_foreign`;') 7 | 8 | this.addSql('alter table `player_presence` modify `player_alias_id` int unsigned null;') 9 | this.addSql('alter table `player_presence` add constraint `player_presence_player_alias_id_foreign` foreign key (`player_alias_id`) references `player_alias` (`id`) on delete cascade;') 10 | } 11 | 12 | override async down(): Promise { 13 | this.addSql('alter table `player_presence` drop foreign key `player_presence_player_alias_id_foreign`;') 14 | 15 | this.addSql('alter table `player_presence` modify `player_alias_id` int unsigned not null;') 16 | this.addSql('alter table `player_presence` add constraint `player_presence_player_alias_id_foreign` foreign key (`player_alias_id`) references `player_alias` (`id`) on update cascade;') 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/migrations/20250402161623ModifyPlayerPropLengths.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class ModifyPlayerPropLengths extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `player_prop` modify `key` varchar(128) not null, modify `value` varchar(512) not null;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `player_prop` modify `key` varchar(255) not null, modify `value` varchar(255) not null;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20250411180623AddGameChannelPrivateColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddGameChannelPrivateColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `game_channel` add `private` tinyint(1) not null default false;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `game_channel` drop column `private`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20250513222143AddPurgeAndWebsiteGameColumns.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddPurgeAndWebsiteGameColumns extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `game` add `purge_dev_players` tinyint(1) not null default false, add `purge_live_players` tinyint(1) not null default false, add `website` varchar(255) null;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `game` drop column `purge_dev_players`, drop column `purge_live_players`, drop column `website`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/20250522212229AddGameChannelTemporaryMembershipColumn.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations' 2 | 3 | export class AddGameChannelTemporaryMembershipColumn extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql('alter table `game_channel` add `temporary_membership` tinyint(1) not null default false;') 7 | } 8 | 9 | override async down(): Promise { 10 | this.addSql('alter table `game_channel` drop column `temporary_membership`;') 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/000CreateMigrationsTable.ts: -------------------------------------------------------------------------------- 1 | export const CreateMigrationsTable = `CREATE TABLE IF NOT EXISTS ${process.env.CLICKHOUSE_DB}.migrations ( 2 | name String, 3 | executed_at DateTime, 4 | PRIMARY KEY (name) 5 | ) ENGINE = MergeTree() 6 | ORDER BY (name, executed_at);` 7 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/001CreateEventsTable.ts: -------------------------------------------------------------------------------- 1 | export const CreateEventsTable = `CREATE TABLE IF NOT EXISTS ${process.env.CLICKHOUSE_DB}.events ( 2 | id String, 3 | name String, 4 | game_id UInt32, 5 | player_alias_id UInt32, 6 | dev_build Boolean, 7 | created_at DateTime, 8 | updated_at DateTime, 9 | PRIMARY KEY (id), 10 | INDEX game_id_idx (game_id) TYPE minmax GRANULARITY 64, 11 | INDEX player_alias_id_idx (player_alias_id) TYPE minmax GRANULARITY 64 12 | ) ENGINE = MergeTree() 13 | ORDER BY (id, created_at, game_id, player_alias_id);` 14 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/002CreateEventPropsTable.ts: -------------------------------------------------------------------------------- 1 | export const CreateEventPropsTable = `CREATE TABLE IF NOT EXISTS ${process.env.CLICKHOUSE_DB}.event_props ( 2 | event_id String, 3 | prop_key String, 4 | prop_value String 5 | ) ENGINE = MergeTree() 6 | ORDER BY (event_id, prop_key);` 7 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/003CreateSocketEventsTable.ts: -------------------------------------------------------------------------------- 1 | export const CreateSocketEventsTable = `CREATE TABLE IF NOT EXISTS ${process.env.CLICKHOUSE_DB}.socket_events ( 2 | id UUID DEFAULT generateUUIDv4(), 3 | event_type String, 4 | req_or_res Enum('req' = 0, 'res' = 1), 5 | code Nullable(String), 6 | game_id UInt32, 7 | player_alias_id Nullable(UInt32), 8 | dev_build Boolean, 9 | created_at DateTime64(3), 10 | PRIMARY KEY (id), 11 | INDEX game_id_idx (game_id) TYPE minmax GRANULARITY 64, 12 | INDEX player_alias_id_idx (player_alias_id) TYPE minmax GRANULARITY 64 13 | ) ENGINE = MergeTree() 14 | ORDER BY (id, created_at, game_id);` 15 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/004CreatePlayerGameStatSnapshotsTable.ts: -------------------------------------------------------------------------------- 1 | export const CreatePlayerGameStatSnapshotsTable = `CREATE TABLE IF NOT EXISTS ${process.env.CLICKHOUSE_DB}.player_game_stat_snapshots ( 2 | id UUID DEFAULT generateUUIDv4(), 3 | player_alias_id UInt32, 4 | game_stat_id UInt32, 5 | change Float64, 6 | value Float64, 7 | global_value Float64, 8 | created_at DateTime64(3), 9 | PRIMARY KEY (id), 10 | INDEX player_alias_id_idx (player_alias_id) TYPE minmax GRANULARITY 64, 11 | INDEX game_stat_id_idx (game_stat_id) TYPE minmax GRANULARITY 64 12 | ) ENGINE = MergeTree() 13 | ORDER BY (id, created_at, player_alias_id, game_stat_id);` 14 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/005MigrateEventsTimestampsToDate64.ts: -------------------------------------------------------------------------------- 1 | export const MigrateEventsTimestampsToDate64 = ` 2 | CREATE TABLE ${process.env.CLICKHOUSE_DB}.events_new 3 | ( 4 | id String, 5 | name String, 6 | game_id UInt32, 7 | player_alias_id UInt32, 8 | dev_build Boolean, 9 | created_at DateTime64(3), 10 | updated_at DateTime64(3), 11 | PRIMARY KEY (id), 12 | INDEX game_id_idx (game_id) TYPE minmax GRANULARITY 64, 13 | INDEX player_alias_id_idx (player_alias_id) TYPE minmax GRANULARITY 64 14 | ) ENGINE = MergeTree() 15 | ORDER BY (id, created_at, game_id, player_alias_id); 16 | 17 | INSERT INTO ${process.env.CLICKHOUSE_DB}.events_new 18 | SELECT id, name, game_id, player_alias_id, dev_build, created_at, updated_at FROM ${process.env.CLICKHOUSE_DB}.events; 19 | 20 | RENAME TABLE ${process.env.CLICKHOUSE_DB}.events TO ${process.env.CLICKHOUSE_DB}.events_old, 21 | ${process.env.CLICKHOUSE_DB}.events_new TO ${process.env.CLICKHOUSE_DB}.events; 22 | 23 | DROP TABLE ${process.env.CLICKHOUSE_DB}.events_old; 24 | ` 25 | -------------------------------------------------------------------------------- /src/migrations/clickhouse/006CreatePlayerSessionsTable.ts: -------------------------------------------------------------------------------- 1 | export const CreatePlayerSessionsTable = `CREATE TABLE IF NOT EXISTS ${process.env.CLICKHOUSE_DB}.player_sessions ( 2 | id UUID DEFAULT generateUUIDv4(), 3 | player_id String, 4 | game_id UInt32, 5 | dev_build Boolean, 6 | started_at DateTime64(3), 7 | ended_at Nullable(DateTime64(3)), 8 | PRIMARY KEY (id), 9 | INDEX game_id_idx (game_id) TYPE minmax GRANULARITY 64, 10 | INDEX player_id_idx (player_id) TYPE minmax GRANULARITY 64 11 | ) ENGINE = MergeTree() 12 | ORDER BY (id, started_at, game_id, player_id);` 13 | -------------------------------------------------------------------------------- /src/policies/api/event-api.policy.ts: -------------------------------------------------------------------------------- 1 | import { PolicyDenial, PolicyResponse } from 'koa-clay' 2 | import { APIKeyScope } from '../../entities/api-key' 3 | import Player from '../../entities/player' 4 | import Policy from '../policy' 5 | 6 | export default class EventAPIPolicy extends Policy { 7 | async post(): Promise { 8 | const key = this.getAPIKey() 9 | this.ctx.state.player = await this.em.getRepository(Player).findOne({ 10 | aliases: { 11 | id: this.ctx.state.currentAliasId 12 | }, 13 | game: key.game 14 | }, { 15 | populate: ['aliases'] 16 | }) 17 | 18 | if (!this.ctx.state.player) return new PolicyDenial({ message: 'Player not found' }, 404) 19 | return await this.hasScope(APIKeyScope.WRITE_EVENTS) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/policies/api/game-config-api.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from '../policy' 2 | import { PolicyResponse } from 'koa-clay' 3 | import { APIKeyScope } from '../../entities/api-key' 4 | 5 | export default class GameConfigAPIPolicy extends Policy { 6 | async index(): Promise { 7 | return await this.hasScope(APIKeyScope.READ_GAME_CONFIG) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/policies/api/game-feedback-api.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from '../policy' 2 | import { PolicyDenial, PolicyResponse, Request } from 'koa-clay' 3 | import { APIKeyScope } from '../../entities/api-key' 4 | import GameFeedbackCategory from '../../entities/game-feedback-category' 5 | import PlayerAlias from '../../entities/player-alias' 6 | 7 | export default class GameFeedbackAPIPolicy extends Policy { 8 | async indexCategories(): Promise { 9 | return await this.hasScope(APIKeyScope.READ_GAME_FEEDBACK) 10 | } 11 | 12 | async post(req: Request): Promise { 13 | const { internalName } = req.params 14 | 15 | const key = this.getAPIKey() 16 | const category = await this.em.getRepository(GameFeedbackCategory).findOne({ 17 | internalName, 18 | game: key.game 19 | }) 20 | 21 | this.ctx.state.category = category 22 | if (!category) return new PolicyDenial({ message: 'Feedback category not found' }, 404) 23 | 24 | const playerAlias = await this.em.getRepository(PlayerAlias).findOne({ 25 | id: Number(this.ctx.state.currentAliasId), 26 | player: { 27 | game: this.ctx.state.key.game 28 | } 29 | }) 30 | 31 | this.ctx.state.alias = playerAlias 32 | if (!playerAlias) return new PolicyDenial({ message: 'Player not found' }, 404) 33 | 34 | return await this.hasScope(APIKeyScope.WRITE_GAME_FEEDBACK) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/policies/api/leaderboard-api.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from '../policy' 2 | import { PolicyDenial, PolicyResponse, Request } from 'koa-clay' 3 | import Leaderboard from '../../entities/leaderboard' 4 | import PlayerAlias from '../../entities/player-alias' 5 | import { APIKeyScope } from '../../entities/api-key' 6 | 7 | export default class LeaderboardAPIPolicy extends Policy { 8 | async getLeaderboard(req: Request): Promise { 9 | const { internalName } = req.params 10 | 11 | const key = this.getAPIKey() 12 | return await this.em.getRepository(Leaderboard).findOne({ 13 | internalName, 14 | game: key.game 15 | }) 16 | } 17 | 18 | async get(req: Request): Promise { 19 | this.ctx.state.leaderboard = await this.getLeaderboard(req) 20 | if (!this.ctx.state.leaderboard) return new PolicyDenial({ message: 'Leaderboard not found' }, 404) 21 | 22 | return await this.hasScope(APIKeyScope.READ_LEADERBOARDS) 23 | } 24 | 25 | async post(req: Request): Promise { 26 | this.ctx.state.leaderboard = await this.getLeaderboard(req) 27 | if (!this.ctx.state.leaderboard) return new PolicyDenial({ message: 'Leaderboard not found' }, 404) 28 | 29 | this.ctx.state.alias = await this.em.getRepository(PlayerAlias).findOne({ 30 | id: Number(this.ctx.state.currentAliasId), 31 | player: { 32 | game: this.ctx.state.key.game 33 | } 34 | }) 35 | 36 | if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) 37 | 38 | return await this.hasScope(APIKeyScope.WRITE_LEADERBOARDS) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/policies/api/player-api.policy.ts: -------------------------------------------------------------------------------- 1 | import { PolicyDenial, PolicyResponse, Request } from 'koa-clay' 2 | import { APIKeyScope } from '../../entities/api-key' 3 | import Player from '../../entities/player' 4 | import Policy from '../policy' 5 | 6 | export default class PlayerAPIPolicy extends Policy { 7 | async identify(): Promise { 8 | return this.hasScope(APIKeyScope.READ_PLAYERS) 9 | } 10 | 11 | async get(): Promise { 12 | return this.hasScope(APIKeyScope.READ_PLAYERS) 13 | } 14 | 15 | async patch(req: Request): Promise { 16 | const { id } = req.params 17 | 18 | const key = this.getAPIKey() 19 | 20 | this.ctx.state.player = await this.em.getRepository(Player).findOne({ 21 | id: id, 22 | game: key.game 23 | }) 24 | 25 | if (!this.ctx.state.player) return new PolicyDenial({ message: 'Player not found' }, 404) 26 | 27 | return await this.hasScope(APIKeyScope.WRITE_PLAYERS) 28 | } 29 | 30 | async merge(): Promise { 31 | return await this.hasScopes([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/policies/api/player-group-api.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from '../policy' 2 | import { PolicyDenial, PolicyResponse, Request } from 'koa-clay' 3 | import { APIKeyScope } from '../../entities/api-key' 4 | import PlayerGroup from '../../entities/player-group' 5 | 6 | export default class PlayerGroupAPIPolicy extends Policy { 7 | async get(req: Request): Promise { 8 | const { id } = req.params 9 | 10 | const key = this.getAPIKey() 11 | const group = await this.em.getRepository(PlayerGroup).findOne({ 12 | id, 13 | game: key.game 14 | }) 15 | 16 | this.ctx.state.group = group 17 | if (!group) return new PolicyDenial({ message: 'Group not found' }, 404) 18 | 19 | return await this.hasScope(APIKeyScope.READ_PLAYER_GROUPS) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/policies/api/player-presence-api.policy.ts: -------------------------------------------------------------------------------- 1 | import { PolicyResponse } from 'koa-clay' 2 | import { APIKeyScope } from '../../entities/api-key' 3 | import Policy from '../policy' 4 | 5 | export default class PlayerPresenceAPIPolicy extends Policy { 6 | async get(): Promise { 7 | return this.hasScope(APIKeyScope.READ_PLAYERS) 8 | } 9 | 10 | async put(): Promise { 11 | return this.hasScope(APIKeyScope.WRITE_PLAYERS) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/policies/billing.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { PolicyResponse } from 'koa-clay' 3 | import UserTypeGate from './user-type-gate' 4 | 5 | export default class OrganisationPolicy extends Policy { 6 | @UserTypeGate([], 'update the organisation pricing plan') 7 | async createCheckoutSession(): Promise { 8 | return true 9 | } 10 | 11 | @UserTypeGate([], 'update the organisation pricing plan') 12 | async confirmPlan(): Promise { 13 | return true 14 | } 15 | 16 | @UserTypeGate([], 'update the organisation pricing plan') 17 | async createPortalSession(): Promise { 18 | return true 19 | } 20 | 21 | @UserTypeGate([], 'view the organisation pricing plan usage') 22 | async usage(): Promise { 23 | return true 24 | } 25 | 26 | @UserTypeGate([], 'view the organisation pricing plan') 27 | async organisationPlan(): Promise { 28 | return true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/policies/checkScope.ts: -------------------------------------------------------------------------------- 1 | import APIKey, { APIKeyScope } from '../entities/api-key' 2 | 3 | export default function checkScope(key: APIKey, scope: APIKeyScope): boolean { 4 | return key.scopes.includes(APIKeyScope.FULL_ACCESS) || key.scopes.includes(scope) 5 | } 6 | -------------------------------------------------------------------------------- /src/policies/data-export.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { Request, PolicyResponse } from 'koa-clay' 3 | import { UserType } from '../entities/user' 4 | import UserTypeGate from './user-type-gate' 5 | import EmailConfirmedGate from './email-confirmed-gate' 6 | 7 | export default class DataExportPolicy extends Policy { 8 | @UserTypeGate([UserType.ADMIN], 'view data exports') 9 | async index(req: Request): Promise { 10 | const { gameId } = req.params 11 | return await this.canAccessGame(Number(gameId)) 12 | } 13 | 14 | @UserTypeGate([UserType.ADMIN], 'create data exports') 15 | @EmailConfirmedGate('create data exports') 16 | async post(req: Request): Promise { 17 | const { gameId } = req.params 18 | return await this.canAccessGame(Number(gameId)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/policies/email-confirmed-gate.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'koa-clay' 2 | import Policy from './policy' 3 | 4 | const EmailConfirmedGate = (action: string) => (tar: Policy, _: string, descriptor: PropertyDescriptor) => { 5 | const base = descriptor.value 6 | 7 | descriptor.value = async function (req: Request) { 8 | if (!req.ctx.state.user.api) { 9 | const user = await tar.getUser(req) 10 | if (!user.emailConfirmed) req.ctx.throw(403, `You need to confirm your email address to ${action}`) 11 | } 12 | 13 | return base.apply(this, [req]) 14 | } 15 | 16 | return descriptor 17 | } 18 | 19 | export default EmailConfirmedGate 20 | -------------------------------------------------------------------------------- /src/policies/event.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { Request } from 'koa-clay' 3 | 4 | export default class EventPolicy extends Policy { 5 | index(req: Request): Promise { 6 | const { gameId } = req.params 7 | return this.canAccessGame(Number(gameId)) 8 | } 9 | 10 | breakdown(req: Request): Promise { 11 | const { gameId } = req.params 12 | return this.canAccessGame(Number(gameId)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/policies/game-activity.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { PolicyResponse, Request } from 'koa-clay' 3 | import { UserType } from '../entities/user' 4 | import UserTypeGate from './user-type-gate' 5 | 6 | export default class GameActivityPolicy extends Policy { 7 | @UserTypeGate([UserType.ADMIN, UserType.DEMO], 'view game activities') 8 | async index(req: Request): Promise { 9 | const { gameId } = req.params 10 | return await this.canAccessGame(Number(gameId)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/policies/game-channel.policy.ts: -------------------------------------------------------------------------------- 1 | import GameChannel from '../entities/game-channel' 2 | import { UserType } from '../entities/user' 3 | import Policy from './policy' 4 | import { PolicyDenial, PolicyResponse, Request } from 'koa-clay' 5 | import UserTypeGate from './user-type-gate' 6 | 7 | export default class GameChannelPolicy extends Policy { 8 | async index(req: Request): Promise { 9 | if (this.isAPICall()) return true 10 | const { gameId } = req.params 11 | return this.canAccessGame(Number(gameId)) 12 | } 13 | 14 | async post(req: Request): Promise { 15 | if (this.isAPICall()) return true 16 | const { gameId } = req.params 17 | return this.canAccessGame(Number(gameId)) 18 | } 19 | 20 | async canAccessChannel(req: Request): Promise { 21 | const { id, gameId } = req.params 22 | 23 | const channel = await this.em.getRepository(GameChannel).findOne(Number(id), { 24 | populate: ['members'] 25 | }) 26 | if (!channel) return new PolicyDenial({ message: 'Game channel not found' }, 404) 27 | 28 | this.ctx.state.channel = channel 29 | 30 | return this.canAccessGame(Number(gameId)) 31 | } 32 | 33 | async put(req: Request): Promise { 34 | if (this.isAPICall()) return true 35 | return this.canAccessChannel(req) 36 | } 37 | 38 | @UserTypeGate([UserType.ADMIN], 'delete game channels') 39 | async delete(req: Request): Promise { 40 | if (this.isAPICall()) return true 41 | return this.canAccessChannel(req) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/policies/game.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { PolicyResponse, Request } from 'koa-clay' 3 | import { UserType } from '../entities/user' 4 | import UserTypeGate from './user-type-gate' 5 | 6 | export default class GamePolicy extends Policy { 7 | @UserTypeGate([], 'view game settings') 8 | async settings(req: Request): Promise { 9 | const { id } = req.params 10 | return this.canAccessGame(Number(id)) 11 | } 12 | 13 | @UserTypeGate([UserType.ADMIN, UserType.DEV], 'create games') 14 | async post(): Promise { 15 | return true 16 | } 17 | 18 | @UserTypeGate([UserType.ADMIN], 'update games') 19 | async patch(req: Request): Promise { 20 | const { id } = req.params 21 | return this.canAccessGame(Number(id)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/policies/headline.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { Request } from 'koa-clay' 3 | 4 | export default class HeadlinePolicy extends Policy { 5 | async index(req: Request): Promise { 6 | const { gameId } = req.params 7 | return await this.canAccessGame(Number(gameId)) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/policies/invite.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { PolicyResponse } from 'koa-clay' 3 | import { UserType } from '../entities/user' 4 | import UserTypeGate from './user-type-gate' 5 | import EmailConfirmedGate from './email-confirmed-gate' 6 | 7 | export default class InvitePolicy extends Policy { 8 | @UserTypeGate([UserType.ADMIN], 'view invites') 9 | async index(): Promise { 10 | return true 11 | } 12 | 13 | @UserTypeGate([UserType.ADMIN], 'create invites') 14 | @EmailConfirmedGate('create invites') 15 | async post(): Promise { 16 | return true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/policies/organisation.policy.ts: -------------------------------------------------------------------------------- 1 | import Policy from './policy' 2 | import { PolicyResponse } from 'koa-clay' 3 | import { UserType } from '../entities/user' 4 | import UserTypeGate from './user-type-gate' 5 | 6 | export default class OrganisationPolicy extends Policy { 7 | @UserTypeGate([UserType.ADMIN], 'view organisation info') 8 | async current(): Promise { 9 | return true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/policies/user-type-gate.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'koa-clay' 2 | import { UserType } from '../entities/user' 3 | import Policy from './policy' 4 | 5 | const UserTypeGate = (types: UserType[], action: string) => (tar: Policy, _: string, descriptor: PropertyDescriptor) => { 6 | const base = descriptor.value 7 | 8 | descriptor.value = async function (req: Request) { 9 | if (!req.ctx.state.user.api) { 10 | const user = await tar.getUser(req) 11 | if (![UserType.OWNER, ...types].includes(user.type)) req.ctx.throw(403, `You do not have permissions to ${action}`) 12 | } 13 | 14 | return base.apply(this, [req]) 15 | } 16 | 17 | return descriptor 18 | } 19 | 20 | export default UserTypeGate 21 | -------------------------------------------------------------------------------- /src/services/api/api-service.ts: -------------------------------------------------------------------------------- 1 | import { Service } from 'koa-clay' 2 | import { Context } from 'koa' 3 | import APIKey from '../../entities/api-key' 4 | import { EntityManager } from '@mikro-orm/mysql' 5 | 6 | export default class APIService extends Service { 7 | async getAPIKey(ctx: Context): Promise { 8 | const key = await (ctx.em).getRepository(APIKey).findOneOrFail(ctx.state.user.sub) 9 | return key 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/services/api/game-config-api.service.ts: -------------------------------------------------------------------------------- 1 | import { HasPermission, Request, Response, Route } from 'koa-clay' 2 | import GameConfigAPIPolicy from '../../policies/api/game-config-api.policy' 3 | import APIService from './api-service' 4 | import { EntityManager } from '@mikro-orm/mysql' 5 | import GameConfigAPIDocs from '../../docs/game-config-api.docs' 6 | 7 | export default class GameConfigAPIService extends APIService { 8 | @Route({ 9 | method: 'GET', 10 | docs: GameConfigAPIDocs.index 11 | }) 12 | @HasPermission(GameConfigAPIPolicy, 'index') 13 | async index(req: Request): Promise { 14 | const em: EntityManager = req.ctx.em 15 | 16 | const key = await this.getAPIKey(req.ctx) 17 | await em.populate(key, ['game']) 18 | 19 | return { 20 | status: 200, 21 | body: { 22 | config: key.game.getLiveConfig() 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/services/api/health-check-api.service.ts: -------------------------------------------------------------------------------- 1 | import { Response, Route } from 'koa-clay' 2 | import APIService from './api-service' 3 | 4 | export default class HealthCheckAPIService extends APIService { 5 | @Route({ 6 | method: 'GET' 7 | }) 8 | async index(): Promise { 9 | return { 10 | status: 204 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/services/api/player-group-api.service.ts: -------------------------------------------------------------------------------- 1 | import { HasPermission, Request, Response, Route } from 'koa-clay' 2 | import APIService from './api-service' 3 | import PlayerGroupAPIPolicy from '../../policies/api/player-group-api.policy' 4 | import PlayerGroup from '../../entities/player-group' 5 | import Player from '../../entities/player' 6 | import PlayerGroupAPIDocs from '../../docs/player-group-api.docs' 7 | import { EntityManager } from '@mikro-orm/mysql' 8 | import { devDataPlayerFilter } from '../../middlewares/dev-data-middleware' 9 | 10 | type PlayerGroupWithCountAndMembers = Pick & { count: number, members?: Player[] } 11 | 12 | export default class PlayerGroupAPIService extends APIService { 13 | @Route({ 14 | method: 'GET', 15 | path: '/:id', 16 | docs: PlayerGroupAPIDocs.get 17 | }) 18 | @HasPermission(PlayerGroupAPIPolicy, 'get') 19 | async get(req: Request): Promise { 20 | const em: EntityManager = req.ctx.em 21 | const group: PlayerGroup = req.ctx.state.group 22 | 23 | const groupWithCountAndMembers: PlayerGroupWithCountAndMembers = await group.toJSONWithCount(em, req.ctx.state.includeDevData) 24 | if (group.membersVisible) { 25 | groupWithCountAndMembers.members = await group.members.loadItems({ 26 | where: req.ctx.state.includeDevData ? {} : devDataPlayerFilter(em) 27 | }) 28 | } 29 | 30 | return { 31 | status: 200, 32 | body: { 33 | group: groupWithCountAndMembers 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/services/api/socket-ticket-api.service.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Route } from 'koa-clay' 2 | import APIService from './api-service' 3 | import { createRedisConnection } from '../../config/redis.config' 4 | import { v4 } from 'uuid' 5 | import Redis from 'ioredis' 6 | import APIKey from '../../entities/api-key' 7 | import SocketTicketAPIDocs from '../../docs/socket-tickets-api.docs' 8 | 9 | export async function createSocketTicket(redis: Redis, key: APIKey, devBuild: boolean): Promise { 10 | const ticket = v4() 11 | const payload = `${key.id}:${devBuild ? '1' : '0'}` 12 | await redis.set(`socketTickets.${ticket}`, payload, 'EX', 300) 13 | 14 | return ticket 15 | } 16 | 17 | export default class SocketTicketAPIService extends APIService { 18 | @Route({ 19 | method: 'POST', 20 | docs: SocketTicketAPIDocs.post 21 | }) 22 | async post(req: Request): Promise { 23 | const redis = createRedisConnection(req.ctx) 24 | 25 | const ticket = await createSocketTicket(redis, req.ctx.state.key, req.headers['x-talo-dev-build'] === '1') 26 | 27 | return { 28 | status: 200, 29 | body: { 30 | ticket 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/services/game-activity.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import { HasPermission, Service, Request, Response, Route } from 'koa-clay' 3 | import GameActivity from '../entities/game-activity' 4 | import User from '../entities/user' 5 | import GameActivityPolicy from '../policies/game-activity.policy' 6 | 7 | export default class GameActivityService extends Service { 8 | @Route({ 9 | method: 'GET' 10 | }) 11 | @HasPermission(GameActivityPolicy, 'index') 12 | async index(req: Request): Promise { 13 | const em: EntityManager = req.ctx.em 14 | const activities = await em.getRepository(GameActivity).find({ 15 | $or: [ 16 | { 17 | game: req.ctx.state.game 18 | }, 19 | { 20 | $and: [ 21 | { 22 | game: null, 23 | user: { 24 | organisation: (req.ctx.state.user as User).organisation 25 | } 26 | } 27 | ] 28 | } 29 | ] 30 | }, { 31 | populate: ['user'] 32 | }) 33 | 34 | return { 35 | status: 200, 36 | body: { 37 | activities 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/services/organisation.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import { HasPermission, Service, Request, Response, Route } from 'koa-clay' 3 | import Game from '../entities/game' 4 | import Invite from '../entities/invite' 5 | import Organisation from '../entities/organisation' 6 | import User from '../entities/user' 7 | import OrganisationPolicy from '../policies/organisation.policy' 8 | 9 | export default class OrganisationService extends Service { 10 | @Route({ 11 | method: 'GET', 12 | path: '/current' 13 | }) 14 | @HasPermission(OrganisationPolicy, 'current') 15 | async current(req: Request): Promise { 16 | const em: EntityManager = req.ctx.em 17 | 18 | const organisation: Organisation = req.ctx.state.user.organisation 19 | 20 | const games = await em.getRepository(Game).find({ 21 | organisation 22 | }, { 23 | populate: ['players'] 24 | }) 25 | 26 | const members = await em.getRepository(User).find({ organisation }) 27 | 28 | const pendingInvites = await em.getRepository(Invite).find({ organisation }) 29 | 30 | return { 31 | status: 200, 32 | body: { 33 | games, 34 | members, 35 | pendingInvites 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/services/public/documentation.service.ts: -------------------------------------------------------------------------------- 1 | import { Response, Route, Service } from 'koa-clay' 2 | 3 | export default class DocumentationService extends Service { 4 | @Route({ 5 | method: 'GET' 6 | }) 7 | async index(): Promise { 8 | return { 9 | status: 200, 10 | body: { 11 | docs: clay.docs 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/services/public/invite-public.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/mysql' 2 | import { Service, Request, Response, Route } from 'koa-clay' 3 | import Invite from '../../entities/invite' 4 | 5 | export default class InvitePublicService extends Service { 6 | @Route({ 7 | method: 'GET', 8 | path: '/:id' 9 | }) 10 | async get(req: Request): Promise { 11 | const { id } = req.params 12 | const em: EntityManager = req.ctx.em 13 | 14 | const invite = await em.getRepository(Invite).findOne({ 15 | token: id 16 | }, { 17 | populate: ['organisation', 'invitedByUser'] 18 | }) 19 | 20 | if (!invite) req.ctx.throw(404, 'Invite not found') 21 | 22 | return { 23 | status: 200, 24 | body: { 25 | invite 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/socket/messages/socketError.ts: -------------------------------------------------------------------------------- 1 | import { captureException, setTag } from '@sentry/node' 2 | import { sendMessage, SocketMessageRequest } from './socketMessage' 3 | import SocketConnection from '../socketConnection' 4 | 5 | const errorCodes = [ 6 | 'INVALID_MESSAGE', 7 | 'INVALID_MESSAGE_DATA', 8 | 'NO_PLAYER_FOUND', 9 | 'UNHANDLED_REQUEST', 10 | 'ROUTING_ERROR', 11 | 'LISTENER_ERROR', 12 | 'INVALID_SOCKET_TOKEN', 13 | 'INVALID_SESSION_TOKEN', 14 | 'MISSING_ACCESS_KEY_SCOPES', 15 | 'RATE_LIMIT_EXCEEDED' 16 | ] as const 17 | 18 | export type SocketErrorCode = typeof errorCodes[number] 19 | 20 | const validSentryErrorCodes: SocketErrorCode[] = [ 21 | 'UNHANDLED_REQUEST', 22 | 'ROUTING_ERROR', 23 | 'LISTENER_ERROR', 24 | 'RATE_LIMIT_EXCEEDED' 25 | ] 26 | 27 | export default class SocketError { 28 | constructor(public code: SocketErrorCode, public message: string, public cause?: string) { } 29 | } 30 | 31 | type SocketErrorReq = SocketMessageRequest | 'unknown' 32 | 33 | export async function sendError(conn: SocketConnection, req: SocketErrorReq, error: SocketError) { 34 | if (validSentryErrorCodes.includes(error.code)) { 35 | setTag('request', req) 36 | setTag('errorCode', error.code) 37 | captureException(new Error(error.message, { cause: error })) 38 | } 39 | 40 | await sendMessage<{ 41 | req: SocketErrorReq 42 | message: string 43 | errorCode: SocketErrorCode 44 | cause?: string 45 | }>(conn, 'v1.error', { 46 | req, 47 | message: error.message, 48 | errorCode: error.code, 49 | cause: error.cause 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /src/socket/messages/socketMessage.ts: -------------------------------------------------------------------------------- 1 | import SocketConnection from '../socketConnection' 2 | 3 | export const requests = [ 4 | 'v1.players.identify', 5 | 'v1.channels.message' 6 | ] as const 7 | 8 | export type SocketMessageRequest = typeof requests[number] 9 | 10 | export const responses = [ 11 | 'v1.connected', 12 | 'v1.error', 13 | 'v1.players.identify.success', 14 | 'v1.channels.player-joined', 15 | 'v1.channels.player-left', 16 | 'v1.channels.message', 17 | 'v1.channels.deleted', 18 | 'v1.channels.ownership-transferred', 19 | 'v1.live-config.updated', 20 | 'v1.players.presence.updated', 21 | 'v1.channels.updated', 22 | 'v1.channels.storage.updated' 23 | ] as const 24 | 25 | export type SocketMessageResponse = typeof responses[number] 26 | 27 | export async function sendMessage(conn: SocketConnection, res: SocketMessageResponse, data: T) { 28 | await conn.sendMessage(res, data) 29 | } 30 | 31 | export async function sendMessages(conns: SocketConnection[], type: SocketMessageResponse, data: T) { 32 | await Promise.all(conns.map((conn) => conn.sendMessage(type, data))) 33 | } 34 | -------------------------------------------------------------------------------- /src/socket/router/createListener.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from 'zod' 2 | import { SocketMessageRequest } from '../messages/socketMessage' 3 | import SocketConnection from '../socketConnection' 4 | import Socket from '..' 5 | import { APIKeyScope } from '../../entities/api-key' 6 | 7 | type SocketMessageListenerHandlerParams = { 8 | conn: SocketConnection 9 | req: SocketMessageRequest 10 | data: T 11 | socket: Socket 12 | } 13 | 14 | type SocketMessageListenerHandler = (params: SocketMessageListenerHandlerParams) => void | Promise 15 | type SocketMessageListenerOptions = { 16 | requirePlayer?: boolean 17 | apiKeyScopes?: APIKeyScope[] 18 | } 19 | 20 | export type SocketMessageListener = { 21 | req: SocketMessageRequest 22 | validator: T 23 | handler: SocketMessageListenerHandler> 24 | options?: SocketMessageListenerOptions 25 | } 26 | 27 | export default function createListener( 28 | req: SocketMessageRequest, 29 | validator: T, 30 | handler: SocketMessageListenerHandler>, 31 | options?: SocketMessageListenerOptions 32 | ): SocketMessageListener { 33 | return { 34 | req, 35 | validator, 36 | handler, 37 | options 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/socket/socketTicket.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from 'ioredis' 2 | import APIKey from '../entities/api-key' 3 | import { RequestContext } from '@mikro-orm/mysql' 4 | 5 | export default class SocketTicket { 6 | apiKey!: APIKey 7 | devBuild!: boolean 8 | 9 | constructor(private readonly ticket: string) { } 10 | 11 | async validate(redis: Redis): Promise { 12 | const ticketValue = await redis.get(`socketTickets.${this.ticket}`) 13 | if (ticketValue) { 14 | await redis.del(`socketTickets.${this.ticket}`) 15 | const [keyId, devBuild] = ticketValue.split(':') 16 | 17 | try { 18 | this.devBuild = devBuild === '1' 19 | 20 | const em = RequestContext.getEntityManager()! 21 | this.apiKey = await em.getRepository(APIKey).findOneOrFail({ 22 | id: Number(keyId), 23 | revokedAt: null 24 | }, { 25 | populate: ['game'] 26 | }) 27 | 28 | return true 29 | } catch (error) { 30 | return false 31 | } 32 | } 33 | return false 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/fixtures/DataExportFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import DataExport, { DataExportStatus } from '../../src/entities/data-export' 3 | import Game from '../../src/entities/game' 4 | import UserFactory from './UserFactory' 5 | 6 | export default class DataExportFactory extends Factory { 7 | private game: Game 8 | 9 | constructor(game: Game) { 10 | super(DataExport) 11 | 12 | this.game = game 13 | } 14 | 15 | protected definition(): void { 16 | this.state(async () => { 17 | const createdByUser = await new UserFactory().one() 18 | 19 | return { 20 | status: DataExportStatus.REQUESTED, 21 | createdByUser, 22 | game: this.game 23 | } 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/fixtures/EventFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import Event from '../../src/entities/event' 3 | import Player from '../../src/entities/player' 4 | import { sub } from 'date-fns' 5 | import randomDate from '../../src/lib/dates/randomDate' 6 | import { generateEventData } from '../../src/lib/demo-data/generateDemoEvents' 7 | import { rand } from '@ngneat/falso' 8 | 9 | export default class EventFactory extends Factory { 10 | private availablePlayers: Player[] 11 | 12 | constructor(availablePlayers: Player[]) { 13 | super(Event) 14 | this.availablePlayers = availablePlayers 15 | } 16 | 17 | protected definition(): void { 18 | const player: Player = rand(this.availablePlayers) 19 | 20 | this.state(() => ({ 21 | ...generateEventData(new Date()), 22 | game: player.game, 23 | playerAlias: rand(player.aliases.getItems()) 24 | })) 25 | } 26 | 27 | thisWeek(): this { 28 | return this.state(() => ({ 29 | createdAt: randomDate(sub(new Date(), { weeks: 1 }), new Date()) 30 | })) 31 | } 32 | 33 | thisMonth(): this { 34 | return this.state(() => ({ 35 | createdAt: randomDate(sub(new Date(), { months: 1 }), new Date()) 36 | })) 37 | } 38 | 39 | thisYear(): this { 40 | return this.state(() => ({ 41 | createdAt: randomDate(sub(new Date(), { years: 1 }), new Date()) 42 | })) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/fixtures/GameActivityFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameActivity, { GameActivityType } from '../../src/entities/game-activity' 3 | import Game from '../../src/entities/game' 4 | import User from '../../src/entities/user' 5 | import { rand, randWord } from '@ngneat/falso' 6 | 7 | export default class GameActivityFactory extends Factory { 8 | private availableGames: Game[] 9 | private availableUsers: User[] 10 | 11 | constructor(availableGames: Game[], availableUsers: User[]) { 12 | super(GameActivity) 13 | 14 | this.availableGames = availableGames 15 | this.availableUsers = availableUsers 16 | } 17 | 18 | protected definition(): void { 19 | const type: GameActivityType = rand([GameActivityType.LEADERBOARD_DELETED]) 20 | const extra: { [key: string]: unknown } = {} 21 | 22 | switch (type) { 23 | case GameActivityType.LEADERBOARD_DELETED: 24 | extra.leaderboard = randWord() 25 | break 26 | } 27 | 28 | this.state(() => ({ 29 | game: this.availableGames.length > 0 ? rand(this.availableGames) : undefined, 30 | user: rand(this.availableUsers), 31 | type, 32 | extra 33 | })) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/fixtures/GameChannelFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameChannel from '../../src/entities/game-channel' 3 | import { randText } from '@ngneat/falso' 4 | import Game from '../../src/entities/game' 5 | import PlayerFactory from './PlayerFactory' 6 | 7 | export default class GameChannelFactory extends Factory { 8 | private game: Game 9 | 10 | constructor(game: Game) { 11 | super(GameChannel) 12 | 13 | this.game = game 14 | } 15 | 16 | protected definition(): void { 17 | this.state(async () => ({ 18 | name: randText(), 19 | owner: (await new PlayerFactory([this.game]).one()).aliases[0], 20 | game: this.game, 21 | private: false 22 | })) 23 | } 24 | 25 | private() { 26 | return this.state(() => ({ 27 | private: true 28 | })) 29 | } 30 | 31 | temporaryMembership(): this { 32 | return this.state(() => ({ 33 | temporaryMembership: true 34 | })) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/fixtures/GameChannelStoragePropFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameChannelStorageProp from '../../src/entities/game-channel-storage-prop' 3 | import GameChannel from '../../src/entities/game-channel' 4 | import { randWord } from '@ngneat/falso' 5 | import PlayerAliasFactory from './PlayerAliasFactory' 6 | 7 | export default class GameChannelStoragePropFactory extends Factory { 8 | private channel: GameChannel 9 | 10 | constructor(channel: GameChannel) { 11 | super(GameChannelStorageProp) 12 | this.channel = channel 13 | } 14 | 15 | protected definition(): void { 16 | this.state(async () => { 17 | const playerAlias = await new PlayerAliasFactory(this.channel.owner!.player).one() 18 | 19 | return { 20 | gameChannel: this.channel, 21 | key: randWord(), 22 | value: randWord(), 23 | createdBy: playerAlias, 24 | lastUpdatedBy: playerAlias 25 | } 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/fixtures/GameFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import Game from '../../src/entities/game' 3 | import Organisation from '../../src/entities/organisation' 4 | import Prop from '../../src/entities/prop' 5 | import GameSecret from '../../src/entities/game-secret' 6 | import { rand, randNumber } from '@ngneat/falso' 7 | 8 | export default class GameFactory extends Factory { 9 | private organisation: Organisation 10 | 11 | constructor(organisation: Organisation) { 12 | super(Game) 13 | 14 | this.organisation = organisation 15 | } 16 | 17 | protected definition(): void { 18 | const availableProps = ['xpRate', 'maxLevel', 'halloweenEventNumber', 'christmasEventNumber', 'availableRooms', 'maxPlayersPerServer', 'structuresBuilt', 'maxCurrency'] 19 | 20 | this.state(() => { 21 | const propsCount = randNumber({ max: 3 }) 22 | const props: Prop[] = [] 23 | 24 | for (let i = 0; i < propsCount; i++) { 25 | props.push({ 26 | key: rand(availableProps), 27 | value: String(randNumber({ max: 99 })) 28 | }) 29 | } 30 | 31 | return { 32 | name: rand(['Crawle', 'ISMAK', 'Sorce', 'The Trial', 'You Only Got One Shot', 'Vigilante 2084', 'Trigeon', 'Twodoors', 'Keyboard Twister', 'Spacewatch', 'I Wanna Be The Ghostbuster', 'In Air', 'Superstatic', 'Heart Heist', 'Entropy', 'Shattered', 'Boatyio', 'Scrunk', 'No-thing Island', 'Night Keeper', 'Curse of the Loop', 'Shook']), 33 | organisation: this.organisation, 34 | props, 35 | apiSecret: new GameSecret() 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/fixtures/GameFeedbackCategoryFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameFeedbackCategory from '../../src/entities/game-feedback-category' 3 | import Game from '../../src/entities/game' 4 | import { randBoolean, randSlug, randText } from '@ngneat/falso' 5 | 6 | export default class GameFeedbackCategoryFactory extends Factory { 7 | private game: Game 8 | 9 | constructor(game: Game) { 10 | super(GameFeedbackCategory) 11 | 12 | this.game = game 13 | } 14 | 15 | protected definition(): void { 16 | this.state(() => ({ 17 | internalName: randSlug(), 18 | name: randText(), 19 | description: randText(), 20 | game: this.game, 21 | anonymised: randBoolean() 22 | })) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/fixtures/GameFeedbackFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameFeedback from '../../src/entities/game-feedback' 3 | import GameFeedbackCategoryFactory from './GameFeedbackCategoryFactory' 4 | import Game from '../../src/entities/game' 5 | import PlayerFactory from './PlayerFactory' 6 | import { rand, randText } from '@ngneat/falso' 7 | 8 | export default class GameFeedbackFactory extends Factory { 9 | private game: Game 10 | 11 | constructor(game: Game) { 12 | super(GameFeedback) 13 | 14 | this.game = game 15 | } 16 | 17 | protected definition(): void { 18 | this.state(async () => { 19 | const category = await new GameFeedbackCategoryFactory(this.game).one() 20 | const player = await new PlayerFactory([this.game]).one() 21 | 22 | return { 23 | category, 24 | comment: randText(), 25 | anonymised: category.anonymised, 26 | playerAlias: rand(player.aliases.getItems()) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/fixtures/GameSaveFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameSave from '../../src/entities/game-save' 3 | import Player from '../../src/entities/player' 4 | import randomDate from '../../src/lib/dates/randomDate' 5 | import { sub } from 'date-fns' 6 | import { rand, randFloat, randNumber, randText, randUuid } from '@ngneat/falso' 7 | 8 | export default class GameSaveFactory extends Factory { 9 | private availablePlayers: Player[] 10 | 11 | constructor(availablePlayers: Player[]) { 12 | super(GameSave) 13 | 14 | this.availablePlayers = availablePlayers 15 | } 16 | 17 | protected definition(): void { 18 | const objects = [...new Array(randNumber({ min: 2, max: 5 }))].map(() => ({ 19 | id: randUuid(), 20 | name: randText(), 21 | data: [ 22 | { 23 | key: 'x', 24 | value: String(randFloat({ min: -99, max: 99 })), 25 | dataType: 'System.Single' 26 | }, 27 | { 28 | key: 'y', 29 | value: String(randFloat({ min: -99, max: 99 })), 30 | dataType: 'System.Single' 31 | }, 32 | { 33 | key: 'z', 34 | value: String(randFloat({ min: -99, max: 99 })), 35 | dataType: 'System.Single' 36 | } 37 | ] 38 | })) 39 | 40 | const player = rand(this.availablePlayers) 41 | 42 | this.state(() => ({ 43 | name: `save-level${randNumber({ min: 1, max: 20 })}-${Date.now()}`, 44 | content: { 45 | objects 46 | }, 47 | player, 48 | updatedAt: randomDate(sub(new Date(), { weeks: 2 }), new Date()) 49 | })) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/fixtures/GameStatFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import GameStat from '../../src/entities/game-stat' 3 | import Game from '../../src/entities/game' 4 | import { rand, randBoolean, randNumber, randSlug, randText } from '@ngneat/falso' 5 | 6 | export default class GameStatFactory extends Factory { 7 | private availableGames: Game[] 8 | 9 | constructor(availableGames: Game[]) { 10 | super(GameStat) 11 | 12 | this.availableGames = availableGames 13 | } 14 | 15 | protected definition(): void { 16 | this.state(() => { 17 | const global = randBoolean() 18 | const minValue = randNumber({ min: -999, max: -1 }) 19 | const maxValue = randNumber({ min: 1, max: 999 }) 20 | const defaultValue = randNumber({ min: minValue, max: maxValue }) 21 | 22 | return { 23 | game: rand(this.availableGames), 24 | internalName: randSlug(), 25 | name: randText(), 26 | global, 27 | minValue: randBoolean() ? minValue : null, 28 | maxValue: randBoolean() ? maxValue : null, 29 | defaultValue, 30 | globalValue: defaultValue, 31 | maxChange: randNumber({ max: 1000 }), 32 | minTimeBetweenUpdates: randNumber({ max: 5 }) 33 | } 34 | }) 35 | } 36 | 37 | global(): this { 38 | return this.state(() => ({ 39 | global: true 40 | })) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/fixtures/IntegrationConfigFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import { IntegrationConfig } from '../../src/entities/integration' 3 | import { randBoolean, randNumber, randUuid } from '@ngneat/falso' 4 | 5 | class IntegrationConfigProvider implements IntegrationConfig { 6 | apiKey!: string 7 | appId!: number 8 | syncLeaderboards!: boolean 9 | syncStats!: boolean 10 | } 11 | 12 | export default class IntegrationConfigFactory extends Factory { 13 | constructor() { 14 | super(IntegrationConfigProvider) 15 | } 16 | 17 | protected definition(): void { 18 | this.state(() => { 19 | return { 20 | apiKey: randUuid().replace(/-/g, ''), 21 | appId: randNumber({ min: 100000, max: 999999 }), 22 | syncLeaderboards: randBoolean(), 23 | syncStats: randBoolean() 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/fixtures/IntegrationFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import Integration from '../../src/entities/integration' 3 | 4 | export default class IntegrationFactory extends Factory { 5 | constructor() { 6 | super(Integration) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/InviteFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import Invite from '../../src/entities/invite' 3 | import { UserType } from '../../src/entities/user' 4 | import UserFactory from './UserFactory' 5 | import { rand, randEmail } from '@ngneat/falso' 6 | 7 | export default class InviteFactory extends Factory { 8 | constructor() { 9 | super(Invite) 10 | } 11 | 12 | protected definition(): void { 13 | this.state(async (invite) => { 14 | const invitedByUser = await new UserFactory().state(() => ({ organisation: invite.organisation })).one() 15 | 16 | return { 17 | email: randEmail(), 18 | type: rand([UserType.DEV, UserType.ADMIN]), 19 | invitedByUser 20 | } 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/fixtures/LeaderboardEntryFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import LeaderboardEntry from '../../src/entities/leaderboard-entry' 3 | import Leaderboard from '../../src/entities/leaderboard' 4 | import Player from '../../src/entities/player' 5 | import { rand, randFloat } from '@ngneat/falso' 6 | 7 | export default class LeaderboardEntryFactory extends Factory { 8 | private leaderboard: Leaderboard 9 | private availablePlayers: Player[] 10 | 11 | constructor(leaderboard: Leaderboard, availablePlayers: Player[]) { 12 | super(LeaderboardEntry) 13 | 14 | this.leaderboard = leaderboard 15 | this.availablePlayers = availablePlayers 16 | } 17 | 18 | protected definition(): void { 19 | this.state(async () => { 20 | const player: Player = rand(this.availablePlayers) 21 | await player.aliases.loadItems() 22 | 23 | return { 24 | leaderboard: this.leaderboard, 25 | playerAlias: rand(player.aliases.getItems()), 26 | score: Number(randFloat({ min: 10, max: 100000 })), 27 | hidden: false 28 | } 29 | }) 30 | } 31 | 32 | hidden(): this { 33 | return this.state(() => ({ 34 | hidden: true 35 | })) 36 | } 37 | 38 | archived(): this { 39 | return this.state(() => ({ 40 | deletedAt: new Date() 41 | })) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/fixtures/OrganisationFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import Organisation from '../../src/entities/organisation' 3 | import PricingPlanFactory from './PricingPlanFactory' 4 | import OrganisationPricingPlanFactory from './OrganisationPricingPlanFactory' 5 | import { randCompanyName, randEmail } from '@ngneat/falso' 6 | 7 | export default class OrganisationFactory extends Factory { 8 | constructor() { 9 | super(Organisation) 10 | } 11 | 12 | protected definition(): void { 13 | this.state(async (organisation) => { 14 | const plan = await new PricingPlanFactory().one() 15 | const orgPlan = await new OrganisationPricingPlanFactory().state(() => ({ 16 | organisation, 17 | pricingPlan: plan 18 | })).one() 19 | 20 | return { 21 | email: randEmail(), 22 | name: randCompanyName(), 23 | pricingPlan: orgPlan 24 | } 25 | }) 26 | } 27 | 28 | demo(): this { 29 | return this.state(() => ({ 30 | name: process.env.DEMO_ORGANISATION_NAME 31 | })) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/fixtures/OrganisationPricingPlanFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import OrganisationPricingPlan from '../../src/entities/organisation-pricing-plan' 3 | import { randUuid } from '@ngneat/falso' 4 | 5 | export default class OrganisationPricingPlanFactory extends Factory { 6 | constructor() { 7 | super(OrganisationPricingPlan) 8 | } 9 | 10 | protected definition(): void { 11 | this.state(() => ({ 12 | stripePriceId: `price_${randUuid().split('-')[0]}`, 13 | stripeCustomerId: `cus_${randUuid().split('-')[0]}`, 14 | status: 'active' 15 | })) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/PlayerAliasFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PlayerAlias, { PlayerAliasService } from '../../src/entities/player-alias' 3 | import Player from '../../src/entities/player' 4 | import { rand, randCreditCardNumber, randNumber, randUserName, randUuid } from '@ngneat/falso' 5 | 6 | export default class PlayerAliasFactory extends Factory { 7 | private player: Player 8 | 9 | constructor(player: Player) { 10 | super(PlayerAlias) 11 | 12 | this.player = player 13 | } 14 | 15 | protected definition(): void { 16 | const identifiers = [randUuid(), randUserName(), randCreditCardNumber()] 17 | this.state(() => ({ 18 | service: rand([ 19 | PlayerAliasService.STEAM, 20 | PlayerAliasService.EPIC, 21 | PlayerAliasService.USERNAME, 22 | PlayerAliasService.EMAIL, 23 | PlayerAliasService.CUSTOM 24 | ]), 25 | identifier: rand(identifiers), 26 | player: this.player 27 | })) 28 | } 29 | 30 | steam(): this { 31 | return this.state(() => ({ 32 | service: PlayerAliasService.STEAM, 33 | identifier: randNumber({ min: 100_000, max: 1_000_000 }).toString() 34 | })) 35 | } 36 | 37 | username(): this { 38 | return this.state(() => ({ 39 | service: PlayerAliasService.USERNAME, 40 | identifier: randUserName() 41 | })) 42 | } 43 | 44 | talo(): this { 45 | return this.state(() => ({ 46 | service: PlayerAliasService.TALO, 47 | identifier: randUuid() 48 | })) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/fixtures/PlayerAuthActivityFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PlayerAuthActivity, { PlayerAuthActivityType } from '../../src/entities/player-auth-activity' 3 | import PlayerFactory from './PlayerFactory' 4 | import Game from '../../src/entities/game' 5 | import { rand } from '@ngneat/falso' 6 | 7 | export default class PlayerAuthActivityFactory extends Factory { 8 | game: Game 9 | 10 | constructor(game: Game) { 11 | super(PlayerAuthActivity) 12 | 13 | this.game = game 14 | } 15 | 16 | protected definition(): void { 17 | this.state(async () => ({ 18 | type: rand([ 19 | PlayerAuthActivityType.REGISTERED, 20 | PlayerAuthActivityType.VERIFICATION_STARTED, 21 | PlayerAuthActivityType.LOGGED_IN, 22 | PlayerAuthActivityType.LOGGED_OUT 23 | ]), 24 | player: await new PlayerFactory([this.game]).withTaloAlias().one() 25 | })) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/fixtures/PlayerAuthFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PlayerAuth from '../../src/entities/player-auth' 3 | import { randEmail } from '@ngneat/falso' 4 | 5 | export default class PlayerAuthFactory extends Factory { 6 | constructor() { 7 | super(PlayerAuth) 8 | } 9 | 10 | protected definition(): void { 11 | this.state(() => ({ 12 | email: randEmail(), 13 | password: '' 14 | })) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/PlayerGameStatFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PlayerGameStat from '../../src/entities/player-game-stat' 3 | import { randBoolean, randNumber } from '@ngneat/falso' 4 | 5 | export default class PlayerGameStatFactory extends Factory { 6 | constructor() { 7 | super(PlayerGameStat) 8 | } 9 | 10 | protected definition(): void { 11 | this.state(async ({ stat }) => { 12 | return { 13 | value: randBoolean() 14 | ? stat.defaultValue 15 | : randNumber({ min: stat.minValue ?? undefined, max: stat.maxValue ?? undefined }) 16 | } 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/fixtures/PlayerGroupFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PlayerGroup, { RuleMode } from '../../src/entities/player-group' 3 | import { rand, randText } from '@ngneat/falso' 4 | 5 | export default class PlayerGroupFactory extends Factory { 6 | constructor() { 7 | super(PlayerGroup) 8 | } 9 | 10 | protected definition(): void { 11 | this.state(() => ({ 12 | name: randText(), 13 | description: randText(), 14 | ruleMode: rand([RuleMode.AND, RuleMode.OR]) 15 | })) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/PlayerPresenceFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PlayerPresence from '../../src/entities/player-presence' 3 | import { randBoolean, randText } from '@ngneat/falso' 4 | import PlayerFactory from './PlayerFactory' 5 | import Game from '../../src/entities/game' 6 | import PlayerAlias from '../../src/entities/player-alias' 7 | 8 | export default class PlayerPresenceFactory extends Factory { 9 | private game: Game 10 | 11 | constructor(game: Game) { 12 | super(PlayerPresence) 13 | this.game = game 14 | } 15 | 16 | protected definition(): void { 17 | this.state(async () => { 18 | const player = await new PlayerFactory([this.game]).one() 19 | await player.aliases.loadItems() 20 | 21 | return { 22 | player, 23 | playerAlias: player.aliases[0], 24 | online: randBoolean(), 25 | customStatus: randText() 26 | } 27 | }) 28 | } 29 | 30 | online(): this { 31 | return this.state(() => ({ 32 | online: true 33 | })) 34 | } 35 | 36 | offline(): this { 37 | return this.state(() => ({ 38 | online: false 39 | })) 40 | } 41 | 42 | withAlias(alias: PlayerAlias): this { 43 | return this.state(() => ({ 44 | playerAlias: alias 45 | })) 46 | } 47 | 48 | withCustomStatus(status: string): this { 49 | return this.state(() => ({ 50 | customStatus: status 51 | })) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/fixtures/PricingPlanFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import PricingPlan from '../../src/entities/pricing-plan' 3 | import { randUuid } from '@ngneat/falso' 4 | 5 | export default class PricingPlanFactory extends Factory { 6 | constructor() { 7 | super(PricingPlan) 8 | } 9 | 10 | protected definition(): void { 11 | this.state(() => ({ 12 | stripeId: `prod_${randUuid().split('-')[0]}`, 13 | playerLimit: 10000 14 | })) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/UserPinnedGroupFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'hefty' 2 | import UserPinnedGroup from '../../src/entities/user-pinned-group' 3 | import UserFactory from './UserFactory' 4 | import PlayerGroupFactory from './PlayerGroupFactory' 5 | import GameFactory from './GameFactory' 6 | import OrganisationFactory from './OrganisationFactory' 7 | 8 | export default class UserPinnedGroupFactory extends Factory { 9 | constructor() { 10 | super(UserPinnedGroup) 11 | } 12 | 13 | protected definition(): void { 14 | this.state(async () => { 15 | const organisation = await new OrganisationFactory().one() 16 | const game = await new GameFactory(organisation).one() 17 | 18 | return { 19 | user: await new UserFactory().state(() => ({ organisation })).one(), 20 | group: await new PlayerGroupFactory().state(() => ({ game })).one() 21 | } 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/lib/lang/upperFirst.test.ts: -------------------------------------------------------------------------------- 1 | import upperFirst from '../../../src/lib/lang/upperFirst' 2 | 3 | describe('upperFirst', () => { 4 | it('should uppercase the first character in a string', () => { 5 | expect(upperFirst('steamworks')).toBe('Steamworks') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /tests/lib/queues/createQueue.test.ts: -------------------------------------------------------------------------------- 1 | import { differenceInDays } from 'date-fns' 2 | import FailedJob from '../../../src/entities/failed-job' 3 | import createQueue from '../../../src/lib/queues/createQueue' 4 | 5 | describe('Create queue', () => { 6 | it('should put failed jobs in the database', async () => { 7 | const payload = { message: 'knock knock' } 8 | 9 | const processMock = vi.fn().mockImplementation(async () => { 10 | throw new Error('Something went wrong') 11 | }) 12 | 13 | const queue = createQueue('test', processMock) 14 | await queue.add('test-job', payload) 15 | 16 | expect(processMock).toHaveBeenCalledTimes(1) 17 | 18 | const failedJobs = await em.getRepository(FailedJob).findAll() 19 | expect(failedJobs).toHaveLength(1) 20 | const failedJob = failedJobs[0] 21 | 22 | expect(failedJob.queue).toBe('test') 23 | expect(failedJob.payload).toStrictEqual(payload) 24 | expect(failedJob.reason).toBe('Something went wrong') 25 | expect(differenceInDays(new Date(failedJob.failedAt), Date.now())).toBeLessThan(1) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/migrateClickHouse.ts: -------------------------------------------------------------------------------- 1 | import createClickHouseClient from '../src/lib/clickhouse/createClient' 2 | import { runClickHouseMigrations } from '../src/migrations/clickhouse' 3 | 4 | async function run() { 5 | const clickhouse = await createClickHouseClient() 6 | await runClickHouseMigrations(clickhouse) 7 | await clickhouse.close() 8 | } 9 | 10 | run() 11 | -------------------------------------------------------------------------------- /tests/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export $(cat envs/.env.test | xargs) 4 | 5 | if [ -z "$CI" ]; then 6 | alias dc="docker compose -f docker-compose.test.yml" 7 | else 8 | alias dc="docker compose -f docker-compose.test.yml -f docker-compose.ci.yml" 9 | fi 10 | 11 | trap "cleanup" EXIT 12 | 13 | cleanup() { 14 | if [ -z "$CI" ] 15 | then 16 | dc down -v 17 | fi 18 | } 19 | 20 | set -e 21 | 22 | dc up -d 23 | 24 | npx mikro-orm migration:up 25 | tsx ./tests/migrateClickHouse.ts 26 | echo "\n" 27 | 28 | if [ -z "$EXPOSE_GC" ] 29 | then 30 | node --trace-warnings ./node_modules/.bin/vitest "$@" 31 | else 32 | node --expose-gc --trace-warnings ./node_modules/.bin/vitest "$@" --logHeapUsage 33 | fi 34 | -------------------------------------------------------------------------------- /tests/services/_api/game-config-api/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { APIKeyScope } from '../../../../src/entities/api-key' 3 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 4 | 5 | describe('Game config API service - index', () => { 6 | it('should return the game config if the scope is valid', async () => { 7 | const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CONFIG]) 8 | await em.populate(apiKey, ['game']) 9 | 10 | const res = await request(app) 11 | .get('/v1/game-config') 12 | .auth(token, { type: 'bearer' }) 13 | .expect(200) 14 | 15 | expect(res.body.config).toHaveLength(apiKey.game.props.length) 16 | }) 17 | 18 | it('should not return the game config if the scope is not valid', async () => { 19 | const [, token] = await createAPIKeyAndToken([]) 20 | 21 | await request(app) 22 | .get('/v1/game-config') 23 | .auth(token, { type: 'bearer' }) 24 | .expect(403) 25 | }) 26 | 27 | it('should filter out meta props from the game config', async () => { 28 | const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CONFIG]) 29 | await em.populate(apiKey, ['game']) 30 | apiKey.game.props.push({ key: 'META_PREV_NAME', value: 'LD51' }) 31 | await em.flush() 32 | 33 | const res = await request(app) 34 | .get('/v1/game-config') 35 | .auth(token, { type: 'bearer' }) 36 | .expect(200) 37 | 38 | expect(res.body.config).toHaveLength(apiKey.game.props.length - 1) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /tests/services/_api/game-stat-api/get.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import GameStatFactory from '../../../fixtures/GameStatFactory' 3 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 4 | import { APIKeyScope } from '../../../../src/entities/api-key' 5 | 6 | describe('Game stats API service - get', () => { 7 | it('should get a game stat if the scope is valid', async () => { 8 | const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_STATS]) 9 | const gameStat = await new GameStatFactory([apiKey.game]).one() 10 | await em.persistAndFlush(gameStat) 11 | 12 | await request(app) 13 | .get(`/v1/game-stats/${gameStat.internalName}`) 14 | .auth(token, { type: 'bearer' }) 15 | .expect(200) 16 | }) 17 | 18 | it('should not get a game stat if the scope is invalid', async () => { 19 | const [apiKey, token] = await createAPIKeyAndToken([]) 20 | const gameStat = await new GameStatFactory([apiKey.game]).one() 21 | await em.persistAndFlush(gameStat) 22 | 23 | await request(app) 24 | .get(`/v1/game-stats/${gameStat.internalName}`) 25 | .auth(token, { type: 'bearer' }) 26 | .expect(403) 27 | }) 28 | 29 | it('should return a 404 for a non-existent game stat', async () => { 30 | const [, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_STATS]) 31 | 32 | await request(app) 33 | .get('/v1/game-stats/blah') 34 | .auth(token, { type: 'bearer' }) 35 | .expect(404) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tests/services/_api/game-stat-api/index.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import request from 'supertest' 3 | import GameStatFactory from '../../../fixtures/GameStatFactory' 4 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 5 | import { APIKeyScope } from '../../../../src/entities/api-key' 6 | 7 | describe('Game stats API service - index', () => { 8 | it('should get game stats if the scope is valid', async () => { 9 | const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_STATS]) 10 | const gameStats = await new GameStatFactory([apiKey.game]).many(3) 11 | await em.persistAndFlush([...gameStats]) 12 | 13 | const res = await request(app) 14 | .get('/v1/game-stats') 15 | .auth(token, { type: 'bearer' }) 16 | .expect(200) 17 | 18 | expect(res.body.stats).toHaveLength(gameStats.length) 19 | }) 20 | 21 | it('should not return game stats if the scope is not valid', async () => { 22 | const [, token] = await createAPIKeyAndToken([]) 23 | 24 | await request(app) 25 | .get('/v1/game-stats') 26 | .auth(token, { type: 'bearer' }) 27 | .expect(403) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /tests/services/_api/health-check-api/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 3 | 4 | describe('Health check API service - index', () => { 5 | it('should return a 204', async () => { 6 | const [, token] = await createAPIKeyAndToken([]) 7 | 8 | await request(app) 9 | .get('/v1/health-check') 10 | .auth(token, { type: 'bearer' }) 11 | .expect(204) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /tests/services/_api/player-api/get.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { APIKeyScope } from '../../../../src/entities/api-key' 3 | import PlayerFactory from '../../../fixtures/PlayerFactory' 4 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 5 | 6 | describe('Player API service - get', () => { 7 | it('should get a player by ID', async () => { 8 | const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS]) 9 | const player = await new PlayerFactory([apiKey.game]).one() 10 | await em.persistAndFlush(player) 11 | 12 | const res = await request(app) 13 | .get(`/v1/players/${player.id}`) 14 | .auth(token, { type: 'bearer' }) 15 | .expect(200) 16 | 17 | expect(res.body.player.id).toBe(player.id) 18 | expect(res.body.player.aliases).toHaveLength(player.aliases.length) 19 | }) 20 | 21 | it('should not find a player if the scope is missing', async () => { 22 | const [, token] = await createAPIKeyAndToken([]) 23 | 24 | await request(app) 25 | .get('/v1/players/123') 26 | .auth(token, { type: 'bearer' }) 27 | .expect(403) 28 | }) 29 | 30 | it('should not find a non-existent player', async () => { 31 | const [, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS]) 32 | 33 | const res = await request(app) 34 | .get('/v1/players/non-existent-id') 35 | .auth(token, { type: 'bearer' }) 36 | .expect(404) 37 | 38 | expect(res.body).toStrictEqual({ message: 'Player not found' }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /tests/services/_api/socket-ticket-api/post.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' 3 | 4 | describe('Socket ticket API service - post', () => { 5 | it('should return a valid socket ticket', async () => { 6 | const [, token] = await createAPIKeyAndToken([]) 7 | 8 | const res = await request(app) 9 | .post('/v1/socket-tickets') 10 | .auth(token, { type: 'bearer' }) 11 | .expect(200) 12 | 13 | expect(res.body.ticket).toHaveLength(36) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /tests/services/_public/documentation/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | 3 | describe('Documentation service - index', () => { 4 | it('should return api documentation', async () => { 5 | const res = await request(app) 6 | .get('/public/docs') 7 | .expect(200) 8 | 9 | expect(res.body.docs).toBeTruthy() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /tests/services/_public/invite-public/get.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import InviteFactory from '../../../fixtures/InviteFactory' 3 | import OrganisationFactory from '../../../fixtures/OrganisationFactory' 4 | 5 | describe('Invite public service - get', () => { 6 | it('should return an existing invite', async () => { 7 | const organisation = await new OrganisationFactory().one() 8 | const invite = await new InviteFactory().construct(organisation).one() 9 | await em.persistAndFlush(invite) 10 | 11 | const res = await request(app) 12 | .get(`/public/invites/${invite.token}`) 13 | .expect(200) 14 | 15 | expect(res.body.invite.email).toBe(invite.email) 16 | }) 17 | 18 | it('should not return a missing invite', async () => { 19 | await request(app) 20 | .get('/public/invites/abc123') 21 | .expect(404) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/services/_public/user-public/forgot-password.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import UserFactory from '../../../fixtures/UserFactory' 3 | import * as sendEmail from '../../../../src/lib/messaging/sendEmail' 4 | 5 | describe('User public service - forgot password', () => { 6 | const sendMock = vi.spyOn(sendEmail, 'default') 7 | 8 | afterEach(() => { 9 | sendMock.mockClear() 10 | }) 11 | 12 | it('should let a user request a forgot password email for an existing user', async () => { 13 | const user = await new UserFactory().state(() => ({ password: 'p4ssw0rd' })).one() 14 | await em.persistAndFlush(user) 15 | 16 | await request(app) 17 | .post('/public/users/forgot_password') 18 | .send({ email: user.email }) 19 | .expect(204) 20 | 21 | expect(sendMock).toHaveBeenCalled() 22 | }) 23 | 24 | it('should let a user request a forgot password email for a non-existent user', async () => { 25 | await request(app) 26 | .post('/public/users/forgot_password') 27 | .send({ email: 'blah' }) 28 | .expect(204) 29 | 30 | expect(sendMock).not.toHaveBeenCalled() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tests/services/api-key/get.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import APIKey from '../../../src/entities/api-key' 3 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 4 | import createUserAndToken from '../../utils/createUserAndToken' 5 | 6 | describe('API key service - get', () => { 7 | it('should return a list of api keys', async () => { 8 | const [organisation, game] = await createOrganisationAndGame() 9 | const [token, user] = await createUserAndToken({}, organisation) 10 | 11 | const keys: APIKey[] = [...new Array(3)].map(() => new APIKey(game, user)) 12 | await em.persistAndFlush(keys) 13 | 14 | const res = await request(app) 15 | .get(`/games/${game.id}/api-keys`) 16 | .auth(token, { type: 'bearer' }) 17 | .expect(200) 18 | 19 | expect(res.body.apiKeys).toHaveLength(keys.length) 20 | }) 21 | 22 | it('should not return a list of api keys for a non-existent game', async () => { 23 | const [token] = await createUserAndToken({}) 24 | 25 | const res = await request(app) 26 | .get('/games/99999/api-keys') 27 | .auth(token, { type: 'bearer' }) 28 | .expect(404) 29 | 30 | expect(res.body).toStrictEqual({ message: 'Game not found' }) 31 | }) 32 | 33 | it('should not return a list of api keys for a game the user has no access to', async () => { 34 | const [, game] = await createOrganisationAndGame() 35 | const [token] = await createUserAndToken({}) 36 | 37 | await request(app) 38 | .get(`/games/${game.id}/api-keys`) 39 | .auth(token, { type: 'bearer' }) 40 | .expect(403) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /tests/services/api-key/scopes.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { UserType } from '../../../src/entities/user' 3 | import { APIKeyScope } from '../../../src/entities/api-key' 4 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 5 | import createUserAndToken from '../../utils/createUserAndToken' 6 | 7 | describe('API key service - get scopes', () => { 8 | it('should return a list of api key scopes', async () => { 9 | const [organisation, game] = await createOrganisationAndGame() 10 | const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) 11 | 12 | const res = await request(app) 13 | .get(`/games/${game.id}/api-keys/scopes`) 14 | .auth(token, { type: 'bearer' }) 15 | .expect(200) 16 | 17 | const length = Object.keys(res.body.scopes).reduce((acc, curr) => { 18 | return acc + res.body.scopes[curr].length 19 | }, 0) 20 | expect(length).toBe(Object.keys(APIKeyScope).length - 1) // exclude full access 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/services/billing/usage.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 3 | import createUserAndToken from '../../utils/createUserAndToken' 4 | import userPermissionProvider from '../../utils/userPermissionProvider' 5 | import PlayerFactory from '../../fixtures/PlayerFactory' 6 | 7 | describe('Billing service - usage', () => { 8 | it.each(userPermissionProvider())('should return a %i for a %s user', async (statusCode, _, type) => { 9 | const [organisation] = await createOrganisationAndGame({}, {}) 10 | const [token] = await createUserAndToken({ type }, organisation) 11 | 12 | const limit = 10000 13 | const used = 999 14 | 15 | organisation.pricingPlan.pricingPlan.playerLimit = limit 16 | const players = await new PlayerFactory(organisation.games.getItems()).many(used) 17 | await em.persistAndFlush(players) 18 | 19 | const res = await request(app) 20 | .get('/billing/usage') 21 | .auth(token, { type: 'bearer' }) 22 | .expect(statusCode) 23 | 24 | if (statusCode === 200) { 25 | expect(res.body.usage).toStrictEqual({ 26 | limit, 27 | used 28 | }) 29 | } else { 30 | expect(res.body).toStrictEqual({ message: 'You do not have permissions to view the organisation pricing plan usage' }) 31 | } 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /tests/services/data-export/entities.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { UserType } from '../../../src/entities/user' 3 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 4 | import createUserAndToken from '../../utils/createUserAndToken' 5 | 6 | describe('Data export service - available entities', () => { 7 | it('should return a list of available data export entities', async () => { 8 | const [organisation, game] = await createOrganisationAndGame() 9 | const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) 10 | 11 | const res = await request(app) 12 | .get(`/games/${game.id}/data-exports/entities`) 13 | .auth(token, { type: 'bearer' }) 14 | .expect(200) 15 | 16 | expect(res.body.entities).toStrictEqual([ 'events', 'players', 'playerAliases', 'leaderboardEntries', 'gameStats', 'playerGameStats', 'gameActivities', 'gameFeedback' ]) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/services/data-export/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import DataExportFactory from '../../fixtures/DataExportFactory' 3 | import { UserType } from '../../../src/entities/user' 4 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 5 | import createUserAndToken from '../../utils/createUserAndToken' 6 | 7 | describe('Data export service - index', () => { 8 | it('should return a list of data exports', async () => { 9 | const [organisation, game] = await createOrganisationAndGame() 10 | const [token] = await createUserAndToken({ type: UserType.ADMIN, emailConfirmed: true }, organisation) 11 | 12 | const exports = await new DataExportFactory(game).many(5) 13 | await em.persistAndFlush(exports) 14 | 15 | const res = await request(app) 16 | .get(`/games/${game.id}/data-exports`) 17 | .auth(token, { type: 'bearer' }) 18 | .expect(200) 19 | 20 | expect(res.body.dataExports).toHaveLength(exports.length) 21 | }) 22 | 23 | it('should not return data exports for dev users', async () => { 24 | const [organisation, game] = await createOrganisationAndGame() 25 | const [token] = await createUserAndToken({ type: UserType.DEV, emailConfirmed: true }, organisation) 26 | 27 | const res = await request(app) 28 | .get(`/games/${game.id}/data-exports`) 29 | .auth(token, { type: 'bearer' }) 30 | .expect(403) 31 | 32 | expect(res.body).toStrictEqual({ message: 'You do not have permissions to view data exports' }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/services/invite/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { UserType } from '../../../src/entities/user' 3 | import InviteFactory from '../../fixtures/InviteFactory' 4 | import createUserAndToken from '../../utils/createUserAndToken' 5 | import userPermissionProvider from '../../utils/userPermissionProvider' 6 | 7 | describe('Invite service - index', () => { 8 | it.each(userPermissionProvider([ 9 | UserType.ADMIN 10 | ]))('should return a %i for a %s user', async (statusCode, _, type) => { 11 | const [token, user] = await createUserAndToken({ type }) 12 | 13 | const invites = await new InviteFactory().construct(user.organisation).many(3) 14 | await em.persistAndFlush(invites) 15 | 16 | const res = await request(app) 17 | .get('/invites') 18 | .auth(token, { type: 'bearer' }) 19 | .expect(statusCode) 20 | 21 | if (statusCode === 200) { 22 | expect(res.body.invites).toHaveLength(invites.length) 23 | } else { 24 | expect(res.body).toStrictEqual({ message: 'You do not have permissions to view invites' }) 25 | } 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/services/organisation/current.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { UserType } from '../../../src/entities/user' 3 | import UserFactory from '../../fixtures/UserFactory' 4 | import InviteFactory from '../../fixtures/InviteFactory' 5 | import userPermissionProvider from '../../utils/userPermissionProvider' 6 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 7 | import createUserAndToken from '../../utils/createUserAndToken' 8 | 9 | describe('Organisation service - current', () => { 10 | it.each(userPermissionProvider([ 11 | UserType.ADMIN 12 | ]))('should return a %i for a %s user', async (statusCode, _, type) => { 13 | const [organisation] = await createOrganisationAndGame() 14 | const [token, user] = await createUserAndToken({ type }, organisation) 15 | 16 | const otherUser = await new UserFactory().state(() => ({ organisation })).one() 17 | const invites = await new InviteFactory().construct(organisation).state(() => ({ invitedByUser: user })).many(3) 18 | await em.persistAndFlush([otherUser, ...invites]) 19 | 20 | const res = await request(app) 21 | .get('/organisations/current') 22 | .auth(token, { type: 'bearer' }) 23 | .expect(statusCode) 24 | 25 | if (statusCode === 200) { 26 | expect(res.body.games).toHaveLength(1) 27 | expect(res.body.members).toHaveLength(2) 28 | expect(res.body.pendingInvites).toHaveLength(invites.length) 29 | } else { 30 | expect(res.body).toStrictEqual({ message: 'You do not have permissions to view organisation info' }) 31 | } 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /tests/services/user/enable-2fa.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import UserTwoFactorAuth from '../../../src/entities/user-two-factor-auth' 3 | import createUserAndToken from '../../utils/createUserAndToken' 4 | 5 | describe('User service - enable 2fa', () => { 6 | it('should let users enable 2fa', async () => { 7 | const [token, user] = await createUserAndToken() 8 | 9 | const res = await request(app) 10 | .get('/users/2fa/enable') 11 | .auth(token, { type: 'bearer' }) 12 | .expect(200) 13 | 14 | expect(res.body.qr).toBeTruthy() 15 | 16 | await em.refresh(user) 17 | expect(user.twoFactorAuth).toBeTruthy() 18 | expect(user.twoFactorAuth!.enabled).toBe(false) 19 | }) 20 | 21 | it('should not let users enable 2fa if it is already enabled', async () => { 22 | const [token, user] = await createUserAndToken({ 23 | twoFactorAuth: new UserTwoFactorAuth('blah') 24 | }) 25 | 26 | user.twoFactorAuth!.enabled = true 27 | await em.flush() 28 | 29 | const res = await request(app) 30 | .get('/users/2fa/enable') 31 | .auth(token, { type: 'bearer' }) 32 | .expect(403) 33 | 34 | expect(res.body).toStrictEqual({ message: 'Two factor authentication is already enabled' }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /tests/services/user/get-me.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import createOrganisationAndGame from '../../utils/createOrganisationAndGame' 3 | import createUserAndToken from '../../utils/createUserAndToken' 4 | 5 | describe('User service - get me', () => { 6 | it('should return the user\'s data', async () => { 7 | const [organisation] = await createOrganisationAndGame({}, { name: 'Vigilante 2084' }) 8 | const [token] = await createUserAndToken({}, organisation) 9 | 10 | const res = await request(app) 11 | .get('/users/me') 12 | .auth(token, { type: 'bearer' }) 13 | .expect(200) 14 | 15 | expect(res.body.user).toBeTruthy() 16 | expect(res.body.user.organisation).toBeTruthy() 17 | expect(res.body.user.organisation.games).toHaveLength(1) 18 | expect(res.body.user.organisation.games[0].name).toBe('Vigilante 2084') 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /tests/services/user/logout.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import UserSession from '../../../src/entities/user-session' 3 | import createUserAndToken from '../../utils/createUserAndToken' 4 | 5 | describe('User service - logout', () => { 6 | it('should be able to log a user out and clear sessions', async () => { 7 | const [token, user] = await createUserAndToken() 8 | 9 | const session = new UserSession(user) 10 | session.userAgent = 'testybrowser' 11 | await em.persistAndFlush(session) 12 | 13 | await request(app) 14 | .post('/users/logout') 15 | .set('user-agent', 'testybrowser') 16 | .auth(token, { type: 'bearer' }) 17 | .expect(204) 18 | 19 | const sessions = await em.getRepository(UserSession).find({ user }) 20 | expect(sessions).toHaveLength(0) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/setupTest.ts: -------------------------------------------------------------------------------- 1 | import init from '../src' 2 | 3 | async function truncateTables() { 4 | global.em.execute('SET FOREIGN_KEY_CHECKS = 0;') 5 | 6 | const tables = await global.em.execute(` 7 | SELECT table_name as tableName 8 | FROM information_schema.tables 9 | WHERE table_schema = DATABASE() 10 | `) 11 | 12 | for (const { tableName } of tables) { 13 | await global.em.execute(`TRUNCATE TABLE \`${tableName}\`;`) 14 | } 15 | 16 | global.em.execute('SET FOREIGN_KEY_CHECKS = 1;') 17 | 18 | await (global.clickhouse).command({ 19 | query: `TRUNCATE ALL TABLES from ${process.env.CLICKHOUSE_DB}` 20 | }) 21 | } 22 | 23 | beforeAll(async () => { 24 | vi.mock('nodemailer') 25 | vi.mock('bullmq') 26 | vi.stubEnv('DISABLE_SOCKET_EVENTS', '1') 27 | 28 | const app = await init() 29 | global.app = app.callback() 30 | global.ctx = app.context 31 | global.em = app.context.em.fork() 32 | global.clickhouse = app.context.clickhouse 33 | 34 | await truncateTables() 35 | }) 36 | 37 | afterAll(async () => { 38 | await global.em.getConnection().close(true) 39 | await global.clickhouse.close() 40 | }) 41 | -------------------------------------------------------------------------------- /tests/utils/clearEntities.ts: -------------------------------------------------------------------------------- 1 | export default async function clearEntities(entities: string[]) { 2 | for (const entityName of entities) { 3 | const items = await em.repo(entityName).findAll() 4 | await em.removeAndFlush(items) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/utils/createAPIKeyAndToken.ts: -------------------------------------------------------------------------------- 1 | import APIKey, { APIKeyScope } from '../../src/entities/api-key' 2 | import { createToken } from '../../src/services/api-key.service' 3 | import GameFactory from '../fixtures/GameFactory' 4 | import UserFactory from '../fixtures/UserFactory' 5 | 6 | export default async function createAPIKeyAndToken(scopes: APIKeyScope[]): Promise<[APIKey, string]> { 7 | const user = await new UserFactory().one() 8 | 9 | const game = await new GameFactory(user.organisation).one() 10 | const apiKey = new APIKey(game, user) 11 | apiKey.scopes = scopes 12 | await em.persistAndFlush(apiKey) 13 | 14 | const token = await createToken(em, apiKey) 15 | return [apiKey, token] 16 | } 17 | -------------------------------------------------------------------------------- /tests/utils/createOrganisationAndGame.ts: -------------------------------------------------------------------------------- 1 | import Game from '../../src/entities/game' 2 | import Organisation from '../../src/entities/organisation' 3 | import PricingPlan from '../../src/entities/pricing-plan' 4 | import GameFactory from '../fixtures/GameFactory' 5 | import OrganisationFactory from '../fixtures/OrganisationFactory' 6 | import OrganisationPricingPlanFactory from '../fixtures/OrganisationPricingPlanFactory' 7 | 8 | export default async function createOrganisationAndGame(orgPartial?: Partial, gamePartial?: Partial, plan?: PricingPlan): Promise<[Organisation, Game]> { 9 | const organisation = await new OrganisationFactory().state(() => orgPartial ?? {}).one() 10 | if (plan) { 11 | const orgPlan = await new OrganisationPricingPlanFactory().state(() => ({ 12 | organisation, 13 | pricingPlan: plan 14 | })).one() 15 | organisation.pricingPlan = orgPlan 16 | } 17 | 18 | const game = await new GameFactory(organisation).state(() => gamePartial ?? {}).one() 19 | await em.persistAndFlush([organisation, game]) 20 | 21 | return [organisation, game] 22 | } 23 | -------------------------------------------------------------------------------- /tests/utils/createUserAndToken.ts: -------------------------------------------------------------------------------- 1 | import Organisation from '../../src/entities/organisation' 2 | import User from '../../src/entities/user' 3 | import { genAccessToken } from '../../src/lib/auth/buildTokenPair' 4 | import UserFactory from '../fixtures/UserFactory' 5 | 6 | export default async function createUserAndToken(partial?: Partial, organisation?: Organisation): Promise<[string, User]> { 7 | const user = await new UserFactory().loginable().state(() => partial ?? {}).one() 8 | if (organisation) user.organisation = organisation 9 | await em.persistAndFlush(user) 10 | 11 | const token = await genAccessToken(user) 12 | return [token, user] 13 | } 14 | -------------------------------------------------------------------------------- /tests/utils/userPermissionProvider.ts: -------------------------------------------------------------------------------- 1 | import { UserType } from '../../src/entities/user' 2 | 3 | type UserTypeStatusCodeProvider = [number, string, UserType][] 4 | 5 | export default function userPermissionProvider(allowedUserTypes: UserType[] = [], successCode = 200): UserTypeStatusCodeProvider { 6 | const userTypeMap: Record = { 7 | [UserType.OWNER]: 'owner', 8 | [UserType.ADMIN]: 'admin', 9 | [UserType.DEV]: 'dev', 10 | [UserType.DEMO]: 'demo' 11 | } 12 | 13 | const provider: UserTypeStatusCodeProvider = [ 14 | [successCode, userTypeMap[UserType.OWNER], UserType.OWNER] 15 | ] 16 | 17 | allowedUserTypes.forEach((userType) => provider.push([successCode, userTypeMap[userType], userType])) 18 | 19 | Object.keys(userTypeMap) 20 | .map((key) => Number(key) as UserType) 21 | .filter((userType) => { 22 | return !provider.find((providerItem) => providerItem[1] === userTypeMap[userType]) 23 | }) 24 | .forEach((userType) => { 25 | provider.push([403, userTypeMap[userType], userType]) 26 | }) 27 | 28 | return provider 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "tests", 5 | "__mocks__", 6 | "node_modules", 7 | "dist" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "sourceMap": true, 7 | "outDir": "dist", 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true, 10 | "declaration": true, 11 | "types": ["vitest/globals", "./src/global.d.ts"], 12 | "lib": ["esnext"], 13 | "skipLibCheck": true /* https://github.com/rollup/rollup/issues/5199 and https://github.com/vitejs/vite/issues/14513 */, 14 | "strict": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | watch: false, 6 | globals: true, 7 | setupFiles: './tests/setupTest.ts', 8 | poolOptions: { 9 | forks: { 10 | singleFork: true 11 | } 12 | }, 13 | deps: { 14 | interopDefault: true 15 | }, 16 | coverage: { 17 | provider: 'v8', 18 | reporter: 'lcov', 19 | exclude: [ 20 | '__mocks__', 21 | 'tests', 22 | 'src/index.ts', 23 | 'src/config', 24 | 'src/middlewares/error-middleware.ts', 25 | 'src/middlewares/limiter-middleware.ts', 26 | 'src/migrations', 27 | 'src/global.d.ts', 28 | 'src/lib/clickhouse/clickhouse-entity.ts' 29 | ] 30 | } 31 | } 32 | }) 33 | --------------------------------------------------------------------------------