diff --git a/apps/api/src/cache/leaderboard.ts b/apps/api/src/cache/leaderboard.ts
index 72ba2ef64..bf8cf7c5d 100644
--- a/apps/api/src/cache/leaderboard.ts
+++ b/apps/api/src/cache/leaderboard.ts
@@ -3,7 +3,7 @@ import type { DatabaseClient } from '@rctf/db'
import { challenges, scoreEvents, users } from '@rctf/db'
import { and, asc, eq, gte, inArray, sql } from 'drizzle-orm'
import { inJsonbArray } from '../lib/db-bulk'
-import { challengeIsPublicSql } from '../services/challenges'
+import { challengeIsPublicSql } from '../services/challenge-queries'
import { getCompetitionTiming } from '../services/settings'
import type { TypedRedis } from './scripts'
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 47aa278ab..8224cb6f4 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -9,11 +9,9 @@ import type { AppEnv } from './lib/app-env'
import { runMigrationsOnStartup } from './lib/migrations'
import { appEnvMiddleware } from './middlewares/app-env'
import { dynamicChallengeAuthMiddleware } from './middlewares/dynamic-challenge-auth'
-import {
- adminBotProvider,
- analyticsProvider,
- uploadProvider,
-} from './providers'
+import { adminBotProvider } from './providers/instances/admin-bot'
+import { analyticsProvider } from './providers/instances/analytics'
+import { uploadProvider } from './providers/instances/uploads'
import { routeModules } from './routes'
import { analyticsScriptHandler } from './routes/v2/integrations/routes/get-analytics-script'
import { startLeaderboardWorker, stopWorkers } from './workers'
diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts
deleted file mode 100644
index e282558d3..000000000
--- a/apps/api/src/providers/index.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { config } from '@rctf/config'
-import type { ProviderConfig } from '@rctf/config'
-import { scoreProviders } from '@rctf/scoring'
-import type { ScoreProvider } from '@rctf/scoring'
-import { adminBotProviders } from './admin-bot'
-import { analyticsProviders } from './analytics'
-import { captchaProviders } from './captcha'
-import { emailProviders } from './emails'
-import { instancerProviders } from './instancer'
-import type { InstancerProvider } from './instancer/base'
-import { resolveInstancerConfigs } from './instancer/resolve'
-import { messagesProviders } from './messages'
-import { moderationProviders } from './moderation'
-import { uploadProviders } from './uploads'
-
-const loadProvider = (
- providers: Record Base>,
- providerConfig: ProviderConfig | undefined
-): Base | undefined => {
- if (!providerConfig) {
- return undefined
- }
-
- const provider = providers[providerConfig.name]
- if (!provider) {
- throw new Error(
- `Unsupported provider: ${
- providerConfig.name
- }. Available: ${Object.keys(providers).join(', ')}`
- )
- }
-
- return provider(providerConfig.options)
-}
-
-export const emailProvider = loadProvider(
- emailProviders,
- config.email?.provider
-)
-
-export const uploadProvider = loadProvider(
- uploadProviders,
- config.uploadProvider
-)!
-
-export const scoreProvider = loadProvider(
- scoreProviders,
- config.scoreProvider
-)!
-
-const resolvedInstancers = resolveInstancerConfigs(config)
-export const instancers: Record = Object.fromEntries(
- Object.entries(resolvedInstancers.configs).map(([name, providerConfig]) => [
- name,
- loadProvider(instancerProviders, providerConfig)!,
- ])
-)
-export const defaultInstancerName = resolvedInstancers.defaultName
-export const instancerEnabled = Object.keys(instancers).length > 0
-
-export const captchaProvider = (() => {
- if (config.captcha?.provider) {
- return loadProvider(captchaProviders, config.captcha.provider)
- }
-
- // NOTE(es3n1n): backporting v1 google captcha config
- if (config.recaptcha) {
- const provider = {
- name: 'captcha/recaptcha' as const,
- options: {
- secretKey: config.recaptcha.secretKey,
- siteKey: config.recaptcha.siteKey,
- },
- }
- config.captcha = {
- provider,
- protectedEndpoints: config.recaptcha.protectedActions ?? [],
- }
-
- return loadProvider(captchaProviders, provider)
- }
-
- return undefined
-})()
-
-export const bloodBotProviders = config.bloodBot?.destinations.map(
- ({ provider }) => loadProvider(messagesProviders, provider)!
-)
-
-export const avatarModerationProvider = loadProvider(
- moderationProviders,
- config.avatarsModeration?.provider
-)
-
-export const adminBotProvider = loadProvider(
- adminBotProviders,
- config.adminBot?.provider
-)
-export const adminBotEnabled = adminBotProvider !== undefined
-
-export const analyticsProvider = (() => {
- if (config.analytics?.provider) {
- return loadProvider(analyticsProviders, config.analytics.provider)
- }
-
- // NOTE(es3n1n): backporting v1 google analytics config
- if (config.globalSiteTag) {
- return loadProvider(analyticsProviders, {
- name: 'analytics/google',
- options: { siteTag: config.globalSiteTag },
- })
- }
-
- return undefined
-})()
diff --git a/apps/api/src/providers/instances/admin-bot.ts b/apps/api/src/providers/instances/admin-bot.ts
new file mode 100644
index 000000000..fd0d1831d
--- /dev/null
+++ b/apps/api/src/providers/instances/admin-bot.ts
@@ -0,0 +1,9 @@
+import { config } from '@rctf/config'
+import { adminBotProviders } from '../admin-bot'
+import { loadProvider } from './load'
+
+export const adminBotProvider = loadProvider(
+ adminBotProviders,
+ config.adminBot?.provider
+)
+export const adminBotEnabled = adminBotProvider !== undefined
diff --git a/apps/api/src/providers/instances/all.ts b/apps/api/src/providers/instances/all.ts
new file mode 100644
index 000000000..b57e58f73
--- /dev/null
+++ b/apps/api/src/providers/instances/all.ts
@@ -0,0 +1,9 @@
+import './admin-bot'
+import './analytics'
+import './captcha'
+import './emails'
+import './instancer'
+import './messages'
+import './moderation'
+import './score'
+import './uploads'
diff --git a/apps/api/src/providers/instances/analytics.ts b/apps/api/src/providers/instances/analytics.ts
new file mode 100644
index 000000000..df638d697
--- /dev/null
+++ b/apps/api/src/providers/instances/analytics.ts
@@ -0,0 +1,8 @@
+import { config } from '@rctf/config'
+import { analyticsProviders } from '../analytics'
+import { loadProvider } from './load'
+
+export const analyticsProvider = loadProvider(
+ analyticsProviders,
+ config.analytics?.provider
+)
diff --git a/apps/api/src/providers/instances/captcha.ts b/apps/api/src/providers/instances/captcha.ts
new file mode 100644
index 000000000..87842b7f4
--- /dev/null
+++ b/apps/api/src/providers/instances/captcha.ts
@@ -0,0 +1,8 @@
+import { config } from '@rctf/config'
+import { captchaProviders } from '../captcha'
+import { loadProvider } from './load'
+
+export const captchaProvider = loadProvider(
+ captchaProviders,
+ config.captcha?.provider
+)
diff --git a/apps/api/src/providers/instances/emails.ts b/apps/api/src/providers/instances/emails.ts
new file mode 100644
index 000000000..004a8fd9b
--- /dev/null
+++ b/apps/api/src/providers/instances/emails.ts
@@ -0,0 +1,8 @@
+import { config } from '@rctf/config'
+import { emailProviders } from '../emails'
+import { loadProvider } from './load'
+
+export const emailProvider = loadProvider(
+ emailProviders,
+ config.email?.provider
+)
diff --git a/apps/api/src/providers/instances/instancer.ts b/apps/api/src/providers/instances/instancer.ts
new file mode 100644
index 000000000..882f45ac5
--- /dev/null
+++ b/apps/api/src/providers/instances/instancer.ts
@@ -0,0 +1,15 @@
+import { config } from '@rctf/config'
+import { instancerProviders } from '../instancer'
+import type { InstancerProvider } from '../instancer/base'
+import { resolveInstancerConfigs } from '../instancer/resolve'
+import { loadProvider } from './load'
+
+const resolvedInstancers = resolveInstancerConfigs(config)
+export const instancers: Record = Object.fromEntries(
+ Object.entries(resolvedInstancers.configs).map(([name, providerConfig]) => [
+ name,
+ loadProvider(instancerProviders, providerConfig)!,
+ ])
+)
+export const defaultInstancerName = resolvedInstancers.defaultName
+export const instancerEnabled = Object.keys(instancers).length > 0
diff --git a/apps/api/src/providers/instances/load.ts b/apps/api/src/providers/instances/load.ts
new file mode 100644
index 000000000..e42e929bd
--- /dev/null
+++ b/apps/api/src/providers/instances/load.ts
@@ -0,0 +1,21 @@
+import type { ProviderConfig } from '@rctf/config'
+
+export const loadProvider = (
+ providers: Record Base>,
+ providerConfig: ProviderConfig | undefined
+): Base | undefined => {
+ if (!providerConfig) {
+ return undefined
+ }
+
+ const provider = providers[providerConfig.name]
+ if (!provider) {
+ throw new Error(
+ `Unsupported provider: ${providerConfig.name}. Available: ${Object.keys(
+ providers
+ ).join(', ')}`
+ )
+ }
+
+ return provider(providerConfig.options)
+}
diff --git a/apps/api/src/providers/instances/messages.ts b/apps/api/src/providers/instances/messages.ts
new file mode 100644
index 000000000..59309a7d4
--- /dev/null
+++ b/apps/api/src/providers/instances/messages.ts
@@ -0,0 +1,7 @@
+import { config } from '@rctf/config'
+import { messagesProviders } from '../messages'
+import { loadProvider } from './load'
+
+export const bloodBotProviders = config.bloodBot?.destinations.map(
+ ({ provider }) => loadProvider(messagesProviders, provider)!
+)
diff --git a/apps/api/src/providers/instances/moderation.ts b/apps/api/src/providers/instances/moderation.ts
new file mode 100644
index 000000000..1cbd3f40b
--- /dev/null
+++ b/apps/api/src/providers/instances/moderation.ts
@@ -0,0 +1,8 @@
+import { config } from '@rctf/config'
+import { moderationProviders } from '../moderation'
+import { loadProvider } from './load'
+
+export const avatarModerationProvider = loadProvider(
+ moderationProviders,
+ config.avatarsModeration?.provider
+)
diff --git a/apps/api/src/providers/instances/score.ts b/apps/api/src/providers/instances/score.ts
new file mode 100644
index 000000000..72518fd1c
--- /dev/null
+++ b/apps/api/src/providers/instances/score.ts
@@ -0,0 +1,8 @@
+import { config } from '@rctf/config'
+import { scoreProviders, type ScoreProvider } from '@rctf/scoring'
+import { loadProvider } from './load'
+
+export const scoreProvider = loadProvider(
+ scoreProviders,
+ config.scoreProvider
+)!
diff --git a/apps/api/src/providers/instances/uploads.ts b/apps/api/src/providers/instances/uploads.ts
new file mode 100644
index 000000000..a59658f62
--- /dev/null
+++ b/apps/api/src/providers/instances/uploads.ts
@@ -0,0 +1,8 @@
+import { config } from '@rctf/config'
+import { uploadProviders } from '../uploads'
+import { loadProvider } from './load'
+
+export const uploadProvider = loadProvider(
+ uploadProviders,
+ config.uploadProvider
+)!
diff --git a/apps/api/src/routes/v1/admin/routes/query-uploads.ts b/apps/api/src/routes/v1/admin/routes/query-uploads.ts
index 0d6c09f57..b37858735 100644
--- a/apps/api/src/routes/v1/admin/routes/query-uploads.ts
+++ b/apps/api/src/routes/v1/admin/routes/query-uploads.ts
@@ -1,5 +1,5 @@
import { QueryUploadsRoute } from '@rctf/types'
-import { uploadProvider } from '../../../../providers'
+import { uploadProvider } from '../../../../providers/instances/uploads'
import adminGroup from '../group'
adminGroup.route(QueryUploadsRoute, async ({ body, res }) => {
diff --git a/apps/api/src/routes/v1/admin/routes/upload-files.ts b/apps/api/src/routes/v1/admin/routes/upload-files.ts
index 0b6431c93..1d2a95127 100644
--- a/apps/api/src/routes/v1/admin/routes/upload-files.ts
+++ b/apps/api/src/routes/v1/admin/routes/upload-files.ts
@@ -1,6 +1,6 @@
import { UploadFilesRoute } from '@rctf/types'
import { dataUriToBuffer } from 'data-uri-to-buffer'
-import { uploadProvider } from '../../../../providers'
+import { uploadProvider } from '../../../../providers/instances/uploads'
import adminGroup from '../group'
adminGroup.route(UploadFilesRoute, async ({ res, body }) => {
diff --git a/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts b/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts
index 6777943d5..0341da3f1 100644
--- a/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts
+++ b/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts
@@ -1,6 +1,6 @@
import { config } from '@rctf/config'
import { GetLeaderboardRoute } from '@rctf/types'
-import { getLeaderboardWithTotal } from '../../../../services/leaderboard'
+import { getLeaderboardWithTotal } from '../../../../services/leaderboard-queries'
import leaderboardGroup from '../group'
leaderboardGroup.route(
diff --git a/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts b/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts
index f0de4ec01..3f9e5ee4b 100644
--- a/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts
+++ b/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts
@@ -1,5 +1,5 @@
import { GetAdminBotStatusRouteV2 } from '@rctf/types'
-import { adminBotProvider } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
import adminGroup from '../group'
adminGroup.route(GetAdminBotStatusRouteV2, async ({ res }) => {
diff --git a/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts b/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts
index d9e511f26..25df4cf92 100644
--- a/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts
+++ b/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts
@@ -4,7 +4,7 @@ import {
defaultInstancerName,
instancerEnabled,
instancers,
-} from '../../../../providers'
+} from '../../../../providers/instances/instancer'
import adminGroup from '../group'
adminGroup.route(GetInstancerSchemaRouteV2, async ({ res }) => {
diff --git a/apps/api/src/routes/v2/admin/routes/query-uploads.ts b/apps/api/src/routes/v2/admin/routes/query-uploads.ts
index cc1f4a406..2eeb9a4b6 100644
--- a/apps/api/src/routes/v2/admin/routes/query-uploads.ts
+++ b/apps/api/src/routes/v2/admin/routes/query-uploads.ts
@@ -1,5 +1,5 @@
import { QueryUploadsRouteV2 } from '@rctf/types'
-import { uploadProvider } from '../../../../providers'
+import { uploadProvider } from '../../../../providers/instances/uploads'
import adminGroup from '../group'
adminGroup.route(QueryUploadsRouteV2, async ({ body, res }) => {
diff --git a/apps/api/src/routes/v2/admin/routes/update-challenge.ts b/apps/api/src/routes/v2/admin/routes/update-challenge.ts
index e47d81abd..1aa3859b8 100644
--- a/apps/api/src/routes/v2/admin/routes/update-challenge.ts
+++ b/apps/api/src/routes/v2/admin/routes/update-challenge.ts
@@ -1,6 +1,7 @@
import type { AdminBotConfig } from '@rctf/db'
import { UpdateChallengeRouteV2 } from '@rctf/types'
-import { adminBotProvider, instancerEnabled } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
+import { instancerEnabled } from '../../../../providers/instances/instancer'
import { createDefaultFlag, getFlagProvider } from '../../../../providers/flags'
import {
ChallengeKindChangeBlockedError,
diff --git a/apps/api/src/routes/v2/admin/routes/upload-files.ts b/apps/api/src/routes/v2/admin/routes/upload-files.ts
index 1c2f0505c..71d6fbcff 100644
--- a/apps/api/src/routes/v2/admin/routes/upload-files.ts
+++ b/apps/api/src/routes/v2/admin/routes/upload-files.ts
@@ -1,5 +1,5 @@
import { UploadFilesRouteV2 } from '@rctf/types'
-import { uploadProvider } from '../../../../providers'
+import { uploadProvider } from '../../../../providers/instances/uploads'
import adminGroup from '../group'
adminGroup.route(UploadFilesRouteV2, async ({ res, body }) => {
diff --git a/apps/api/src/routes/v2/challs/routes/get-challenges.ts b/apps/api/src/routes/v2/challs/routes/get-challenges.ts
index 148bfdfd2..2fd3f5e46 100644
--- a/apps/api/src/routes/v2/challs/routes/get-challenges.ts
+++ b/apps/api/src/routes/v2/challs/routes/get-challenges.ts
@@ -1,5 +1,6 @@
import { ChallengeScoringKind, GetChallengesRouteV2 } from '@rctf/types'
-import { adminBotEnabled, instancerEnabled } from '../../../../providers'
+import { adminBotEnabled } from '../../../../providers/instances/admin-bot'
+import { instancerEnabled } from '../../../../providers/instances/instancer'
import { getChallenges } from '../../../../services/challenges'
import {
resolveInstancerActions,
diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts
index 27b4a041d..d6a570f3f 100644
--- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts
+++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts
@@ -1,5 +1,5 @@
import { GetAdminBotConfigRouteV2 } from '@rctf/types'
-import { adminBotProvider } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
import { getChallenge } from '../../../../services/challenges'
import integrationsGroup from '../group'
diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts
index a6451ffe3..7503769a1 100644
--- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts
+++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts
@@ -1,5 +1,5 @@
import { AdminBotJobStatus, GetAdminBotJobHistoryRouteV2 } from '@rctf/types'
-import { adminBotProvider } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
import { getJobHistory } from '../../../../services/admin-bot-jobs'
import { getChallenge } from '../../../../services/challenges'
import integrationsGroup from '../group'
diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts
index f5663594d..73c3444b3 100644
--- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts
+++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts
@@ -1,5 +1,5 @@
import { GetAdminBotJobLogsRouteV2 } from '@rctf/types'
-import { adminBotProvider } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
import { getJobLogs } from '../../../../services/admin-bot-jobs'
import { getChallenge } from '../../../../services/challenges'
import integrationsGroup from '../group'
diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts
index 7f25a2632..6a63ace3d 100644
--- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts
+++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts
@@ -1,5 +1,5 @@
import { AdminBotJobStatus, GetAdminBotJobStatusRouteV2 } from '@rctf/types'
-import { adminBotProvider } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
import { getLatestJob } from '../../../../services/admin-bot-jobs'
import { getChallenge } from '../../../../services/challenges'
import integrationsGroup from '../group'
diff --git a/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts b/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts
index fdd341cf7..a7de75694 100644
--- a/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts
+++ b/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts
@@ -1,6 +1,6 @@
import type { Context } from 'hono'
import type { AppEnv } from '../../../../lib/app-env'
-import { analyticsProvider } from '../../../../providers'
+import { analyticsProvider } from '../../../../providers/instances/analytics'
let cachedScript: { content: string; fetchedAt: number } | null = null
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
diff --git a/apps/api/src/routes/v2/integrations/routes/get-client-config.ts b/apps/api/src/routes/v2/integrations/routes/get-client-config.ts
index c9e334b5a..2b5a02601 100644
--- a/apps/api/src/routes/v2/integrations/routes/get-client-config.ts
+++ b/apps/api/src/routes/v2/integrations/routes/get-client-config.ts
@@ -1,6 +1,7 @@
import { config } from '@rctf/config'
import { GetClientConfigRouteV2, ProtectedAction } from '@rctf/types'
-import { captchaProvider, instancerEnabled } from '../../../../providers'
+import { captchaProvider } from '../../../../providers/instances/captcha'
+import { instancerEnabled } from '../../../../providers/instances/instancer'
import { getResolvedSettings } from '../../../../services/settings'
import integrationsGroup from '../group'
@@ -15,13 +16,6 @@ const getAnalyticsConfig = () => {
}
}
- if (config.globalSiteTag) {
- return {
- provider: 'analytics/google',
- publicOptions: { siteTag: config.globalSiteTag },
- }
- }
-
return null
}
diff --git a/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts b/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts
index 9c46c849c..d9d3ffd0f 100644
--- a/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts
+++ b/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts
@@ -5,7 +5,7 @@ import {
SubmissionResult,
SubmitAdminBotJobRouteV2,
} from '@rctf/types'
-import { adminBotProvider } from '../../../../providers'
+import { adminBotProvider } from '../../../../providers/instances/admin-bot'
import { getFlagsForTeam } from '../../../../providers/flags'
import { createJob, hasActiveJob } from '../../../../services/admin-bot-jobs'
import { getChallenge } from '../../../../services/challenges'
diff --git a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts
index e4790f6d9..c512b923d 100644
--- a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts
+++ b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts
@@ -5,7 +5,7 @@ import { preparedPerDb } from '../../../../lib/prepared'
import {
challengeIsPublicSql,
scoringKindOf,
-} from '../../../../services/challenges'
+} from '../../../../services/challenge-queries'
import leaderboardGroup from '../group'
const preparedLeaderboardChallenges = preparedPerDb(db =>
diff --git a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts
index c86ecdc3f..1e80237fd 100644
--- a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts
+++ b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts
@@ -1,7 +1,7 @@
import { config } from '@rctf/config'
import { GetLeaderboardWithGraphRoute } from '@rctf/types'
import { getGraphForEntries } from '../../../../cache/leaderboard'
-import { getLeaderboardWithFilters } from '../../../../services/leaderboard'
+import { getLeaderboardWithFilters } from '../../../../services/leaderboard-queries'
import { rateLimitSearch } from '../../../../services/rate-limit'
import leaderboardGroup from '../group'
diff --git a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts
index f5b602c1f..b0d52ac28 100644
--- a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts
+++ b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts
@@ -3,7 +3,7 @@ import { GetLeaderboardRouteV2 } from '@rctf/types'
import {
getLeaderboardWithTotal,
searchLeaderboard,
-} from '../../../../services/leaderboard'
+} from '../../../../services/leaderboard-queries'
import { rateLimitSearch } from '../../../../services/rate-limit'
import leaderboardGroup from '../group'
diff --git a/apps/api/src/services/avatar.ts b/apps/api/src/services/avatar.ts
index c5651c526..2a4bc59f3 100644
--- a/apps/api/src/services/avatar.ts
+++ b/apps/api/src/services/avatar.ts
@@ -10,7 +10,8 @@ import type {
import type { PinoLogger } from 'hono-pino'
import sharp from 'sharp'
import type { TypedRedis } from '../cache/scripts'
-import { avatarModerationProvider, uploadProvider } from '../providers'
+import { avatarModerationProvider } from '../providers/instances/moderation'
+import { uploadProvider } from '../providers/instances/uploads'
import { updateUserAvatar } from './users'
export type SetUserAvatarError =
diff --git a/apps/api/src/services/bloodbot.ts b/apps/api/src/services/bloodbot.ts
index 2de57d574..082782a63 100644
--- a/apps/api/src/services/bloodbot.ts
+++ b/apps/api/src/services/bloodbot.ts
@@ -1,7 +1,7 @@
import { config } from '@rctf/config'
import type { ChallengeData, User } from '@rctf/db'
import mustache from 'mustache'
-import { bloodBotProviders } from '../providers'
+import { bloodBotProviders } from '../providers/instances/messages'
import { buildBloodMessageView } from './bloodbot-view'
export const shouldNotifyBloodbot = (bloodNumber: number) => {
diff --git a/apps/api/src/services/challenge-queries.ts b/apps/api/src/services/challenge-queries.ts
new file mode 100644
index 000000000..dfd1a0131
--- /dev/null
+++ b/apps/api/src/services/challenge-queries.ts
@@ -0,0 +1,19 @@
+import { challenges, users } from '@rctf/db'
+import { ChallengeScoringKind } from '@rctf/types'
+import { and, eq, sql } from 'drizzle-orm'
+import type { PgColumn } from 'drizzle-orm/pg-core'
+
+export const userIsNotBanned = eq(users.banned, false)
+export const nonBannedUserJoin = (ownerId: PgColumn) =>
+ and(eq(users.id, ownerId), userIsNotBanned)
+
+export const challengeIsPublicSql = and(
+ sql`COALESCE((${challenges.data} ->> 'hidden')::boolean, false) = false`,
+ sql`COALESCE((${challenges.data} ->> 'releaseTime')::bigint, 0) <= ${sql.raw('extract(epoch from now())::bigint * 1000')}`
+)!
+
+export const scoringKindOf = (data: {
+ scoring?: { kind: ChallengeScoringKind } | null
+}): ChallengeScoringKind => data.scoring?.kind ?? ChallengeScoringKind.DECAY
+
+export const isDecayKind = sql`COALESCE(${challenges.data} -> 'scoring' ->> 'kind', ${ChallengeScoringKind.DECAY}) = ${ChallengeScoringKind.DECAY}`
diff --git a/apps/api/src/services/challenges.ts b/apps/api/src/services/challenges.ts
index 5eba9a94c..6febe5bd6 100644
--- a/apps/api/src/services/challenges.ts
+++ b/apps/api/src/services/challenges.ts
@@ -32,7 +32,6 @@ import {
SubmissionResult,
} from '@rctf/types'
import { and, asc, count, desc, eq, inArray, max, sql, sum } from 'drizzle-orm'
-import type { PgColumn } from 'drizzle-orm/pg-core'
import type { PinoLogger } from 'hono-pino'
import type { TypedRedis } from '../cache/scripts'
import { inJsonbArrayPlaceholder } from '../lib/db-bulk'
@@ -40,6 +39,13 @@ import { preparedPerDb } from '../lib/prepared'
import { type MatchedFlagEntry, verifyFlagEntries } from '../providers/flags'
import { forceLeaderboardUpdate, requestChallengeRecompute } from '../workers'
import { sendBloodMessage, shouldNotifyBloodbot } from './bloodbot'
+import {
+ challengeIsPublicSql,
+ isDecayKind,
+ nonBannedUserJoin,
+ scoringKindOf,
+ userIsNotBanned,
+} from './challenge-queries'
import { rateLimitFlag } from './rate-limit'
import { getCompetitionTiming } from './settings'
import { createSubmission } from './submissions'
@@ -111,10 +117,6 @@ type ChallengeSolvesWithPosition = {
solvePosition: number | null
}
-export const userIsNotBanned = eq(users.banned, false)
-export const nonBannedUserJoin = (ownerId: PgColumn) =>
- and(eq(users.id, ownerId), userIsNotBanned)
-
const createRankedSolvesForChallenges = (
db: DatabaseClient,
challengeIds: string[]
@@ -180,11 +182,6 @@ export const isChallengePublic = (challenge: Challenge): boolean => {
return +new Date() >= (challenge.data.releaseTime ?? 0)
}
-export const challengeIsPublicSql = and(
- sql`COALESCE((${challenges.data} ->> 'hidden')::boolean, false) = false`,
- sql`COALESCE((${challenges.data} ->> 'releaseTime')::bigint, 0) <= ${sql.raw('extract(epoch from now())::bigint * 1000')}`
-)!
-
const challengeDefaultOrder = [
sql`((${challenges.data} ->> 'sortWeight')::int) NULLS LAST`,
desc(challenges.id),
@@ -268,12 +265,7 @@ export const lockChallenge = (tx: DatabaseTx, challengeId: string) =>
sql`SELECT pg_advisory_xact_lock(hashtextextended(${challengeId}, 0))`
)
-export const scoringKindOf = (data: {
- scoring?: { kind: ChallengeScoringKind } | null
-}): ChallengeScoringKind => data.scoring?.kind ?? ChallengeScoringKind.DECAY
-
export type DecayChallenge = Pick
-export const isDecayKind = sql`COALESCE(${challenges.data} -> 'scoring' ->> 'kind', ${ChallengeScoringKind.DECAY}) = ${ChallengeScoringKind.DECAY}`
export const getDecayChallenge = (
tx: DatabaseTx,
diff --git a/apps/api/src/services/emails.ts b/apps/api/src/services/emails.ts
index 28bd79193..4d528603c 100644
--- a/apps/api/src/services/emails.ts
+++ b/apps/api/src/services/emails.ts
@@ -3,7 +3,7 @@ import { config } from '@rctf/config'
import type { DatabaseClient } from '@rctf/db'
import mustache from 'mustache'
import type { TypedRedis } from '../cache/scripts'
-import { emailProvider } from '../providers'
+import { emailProvider } from '../providers/instances/emails'
import { getResolvedSettings } from './settings'
export type EmailKind = 'register' | 'recover' | 'update'
diff --git a/apps/api/src/services/instancer.ts b/apps/api/src/services/instancer.ts
index e462870bd..b12071994 100644
--- a/apps/api/src/services/instancer.ts
+++ b/apps/api/src/services/instancer.ts
@@ -12,7 +12,7 @@ import {
defaultInstancerName,
instancerEnabled,
instancers,
-} from '../providers'
+} from '../providers/instances/instancer'
import { getFlagsForTeam } from '../providers/flags'
import {
type CreateInstanceOptions,
diff --git a/apps/api/src/services/leaderboard.ts b/apps/api/src/services/leaderboard-calculation.ts
similarity index 77%
rename from apps/api/src/services/leaderboard.ts
rename to apps/api/src/services/leaderboard-calculation.ts
index b0f1f299a..3744ae819 100644
--- a/apps/api/src/services/leaderboard.ts
+++ b/apps/api/src/services/leaderboard-calculation.ts
@@ -4,28 +4,22 @@ import { challenges, solves, users } from '@rctf/db'
import { takeUnique } from '@rctf/db/util'
import type { ScoreContext } from '@rctf/scoring/base'
import { ChallengeScoringKind } from '@rctf/types'
-import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'
-import {
- leaderboardOrderSql,
- userIsPublicRankedSql,
- type CalculatedLeaderboard,
- type InternalChallengeInfo,
- type InternalUserInfo,
- type Sample,
+import { and, asc, inArray, sql } from 'drizzle-orm'
+import type {
+ CalculatedLeaderboard,
+ InternalChallengeInfo,
+ InternalUserInfo,
+ Sample,
} from '../cache/leaderboard'
import type { TypedRedis } from '../cache/scripts'
import { cursorAfter, type RowCursor } from '../lib/db-filters'
-import { preparedPerDb } from '../lib/prepared'
-import { scoreProvider } from '../providers'
+import { scoreProvider } from '../providers/instances/score'
import {
challengeIsPublicSql,
- getChallenge,
- getLeaderboardChallengeData,
nonBannedUserJoin,
scoringKindOf,
-} from './challenges'
+} from './challenge-queries'
import { getCompetitionTiming, type CompetitionTiming } from './settings'
-import { userNameSearchFilter } from './users'
const numberOfBloods = 3
@@ -788,234 +782,3 @@ const refreshDynamicContribs = async (
}
return { changed, needsRebuild: false }
}
-
-type LeaderboardEntryRow = Pick<
- typeof users.$inferSelect,
- 'id' | 'name' | 'division' | 'score' | 'globalRank' | 'divisionRank'
->
-
-const leaderboardEntrySelection = {
- id: users.id,
- name: users.name,
- score: users.score,
- division: users.division,
- divisionRank: users.divisionRank,
- globalRank: users.globalRank,
-}
-
-const hydrateLeaderboardEntries = async (
- db: DatabaseClient,
- entries: LeaderboardEntryRow[]
-) => {
- if (entries.length === 0) {
- return []
- }
-
- const {
- solves: userSolves,
- dynamicScores,
- userInfo,
- } = await getLeaderboardChallengeData(
- db,
- entries.map(entry => entry.id)
- )
-
- return entries.map(entry => {
- const info = userInfo.get(entry.id)
- return {
- id: entry.id,
- name: entry.name,
- division: entry.division,
- score: entry.score,
- divisionPlace: entry.divisionRank ?? 0,
- globalPlace: entry.globalRank ?? null,
- avatarUrl: info?.avatarUrl ?? null,
- countryCode: info?.countryCode ?? null,
- statusText: info?.statusText ?? null,
- solves: Array.from(userSolves.get(entry.id) ?? []).map(solve => ({
- id: solve.challengeId,
- solveTime: solve.solveTime,
- })),
- dynamicScores: dynamicScores.get(entry.id) ?? [],
- }
- })
-}
-
-export const searchLeaderboard = async (
- db: DatabaseClient,
- search: string,
- limit: number,
- offset: number,
- division?: string
-) => {
- const searchFilter = userNameSearchFilter(search)
-
- const whereClause = and(
- searchFilter,
- userIsPublicRankedSql,
- division ? eq(users.division, division) : undefined
- )
-
- const [totalRow, matchingUsers] = await Promise.all([
- db
- .select({ count: sql`count(*)::int` })
- .from(users)
- .where(whereClause)
- .then(takeUnique),
- db
- .select(leaderboardEntrySelection)
- .from(users)
- .where(whereClause)
- .orderBy(
- sql`similarity(${users.name}, ${search}) DESC`,
- asc(users.createdAt)
- )
- .limit(limit)
- .offset(offset),
- ])
-
- return {
- total: totalRow?.count ?? 0,
- leaderboard: await hydrateLeaderboardEntries(db, matchingUsers),
- }
-}
-
-const divisionWhereClause = and(
- userIsPublicRankedSql,
- eq(users.division, sql.placeholder('division'))
-)
-
-const preparedLeaderboardQueries = preparedPerDb(db => ({
- count: db
- .select({ count: sql`count(*)::int` })
- .from(users)
- .where(userIsPublicRankedSql)
- .prepare('rctf_leaderboard_count'),
- divisionCount: db
- .select({ count: sql`count(*)::int` })
- .from(users)
- .where(divisionWhereClause)
- .prepare('rctf_leaderboard_count_division'),
- page: db
- .select(leaderboardEntrySelection)
- .from(users)
- .where(userIsPublicRankedSql)
- .orderBy(leaderboardOrderSql)
- .limit(sql.placeholder('limit'))
- .offset(sql.placeholder('offset'))
- .prepare('rctf_leaderboard_page'),
- divisionPage: db
- .select(leaderboardEntrySelection)
- .from(users)
- .where(divisionWhereClause)
- .orderBy(leaderboardOrderSql)
- .limit(sql.placeholder('limit'))
- .offset(sql.placeholder('offset'))
- .prepare('rctf_leaderboard_page_division'),
-}))
-
-export const getLeaderboardWithTotal = async (
- db: DatabaseClient,
- limit: number,
- offset: number,
- division?: string
-) => {
- const prepared = preparedLeaderboardQueries(db)
-
- const [totalRow, leaderboard] = await Promise.all([
- (division
- ? prepared.divisionCount.execute({ division })
- : prepared.count.execute()
- ).then(takeUnique),
- limit > 0
- ? division
- ? prepared.divisionPage.execute({ division, limit, offset })
- : prepared.page.execute({ limit, offset })
- : Promise.resolve([]),
- ])
-
- return {
- total: totalRow?.count ?? 0,
- leaderboard: await hydrateLeaderboardEntries(db, leaderboard),
- }
-}
-
-export const getChallengeLeaderboardWithTotal = async (
- db: DatabaseClient,
- challengeId: string,
- limit: number,
- offset: number,
- division?: string,
- search?: string
-) => {
- const challenge = await getChallenge(db, challengeId)
- if (!challenge) {
- return { total: 0, leaderboard: [] }
- }
-
- const whereClause = and(
- userIsPublicRankedSql,
- division ? eq(users.division, division) : undefined,
- search ? userNameSearchFilter(search) : undefined
- )
- const solveJoin = and(
- eq(solves.userid, users.id),
- eq(solves.challengeid, challengeId)
- )
- const orderBy =
- scoringKindOf(challenge.data) === ChallengeScoringKind.DYNAMIC
- ? [desc(solves.points), asc(solves.pointsUpdatedAt), asc(solves.userid)]
- : [asc(solves.createdat), asc(solves.userid)]
-
- const [totalRow, leaderboard] = await Promise.all([
- db
- .select({ count: sql`count(*)::int` })
- .from(users)
- .innerJoin(solves, solveJoin)
- .where(whereClause)
- .then(takeUnique),
- db
- .select(leaderboardEntrySelection)
- .from(users)
- .innerJoin(solves, solveJoin)
- .where(whereClause)
- .orderBy(...orderBy)
- .limit(limit)
- .offset(offset),
- ])
-
- return {
- total: totalRow?.count ?? 0,
- leaderboard: await hydrateLeaderboardEntries(db, leaderboard),
- }
-}
-
-type LeaderboardFilters = {
- limit: number
- offset: number
- division?: string
- search?: string
- challenge?: string
-}
-
-export const getLeaderboardWithFilters = (
- db: DatabaseClient,
- { limit, offset, division, search, challenge }: LeaderboardFilters
-) => {
- if (challenge) {
- return getChallengeLeaderboardWithTotal(
- db,
- challenge,
- limit,
- offset,
- division,
- search
- )
- }
-
- if (search) {
- return searchLeaderboard(db, search, limit, offset, division)
- }
-
- return getLeaderboardWithTotal(db, limit, offset, division)
-}
diff --git a/apps/api/src/services/leaderboard-queries.ts b/apps/api/src/services/leaderboard-queries.ts
new file mode 100644
index 000000000..33bec40d6
--- /dev/null
+++ b/apps/api/src/services/leaderboard-queries.ts
@@ -0,0 +1,243 @@
+import { solves, users, type DatabaseClient } from '@rctf/db'
+import { takeUnique } from '@rctf/db/util'
+import { ChallengeScoringKind } from '@rctf/types'
+import { and, asc, desc, eq, sql } from 'drizzle-orm'
+import {
+ leaderboardOrderSql,
+ userIsPublicRankedSql,
+} from '../cache/leaderboard'
+import { preparedPerDb } from '../lib/prepared'
+import { scoringKindOf } from './challenge-queries'
+import { getChallenge, getLeaderboardChallengeData } from './challenges'
+import { userNameSearchFilter } from './users'
+
+type LeaderboardEntryRow = Pick<
+ typeof users.$inferSelect,
+ 'id' | 'name' | 'division' | 'score' | 'globalRank' | 'divisionRank'
+>
+
+const leaderboardEntrySelection = {
+ id: users.id,
+ name: users.name,
+ score: users.score,
+ division: users.division,
+ divisionRank: users.divisionRank,
+ globalRank: users.globalRank,
+}
+
+const hydrateLeaderboardEntries = async (
+ db: DatabaseClient,
+ entries: LeaderboardEntryRow[]
+) => {
+ if (entries.length === 0) {
+ return []
+ }
+
+ const {
+ solves: userSolves,
+ dynamicScores,
+ userInfo,
+ } = await getLeaderboardChallengeData(
+ db,
+ entries.map(entry => entry.id)
+ )
+
+ return entries.map(entry => {
+ const info = userInfo.get(entry.id)
+ return {
+ id: entry.id,
+ name: entry.name,
+ division: entry.division,
+ score: entry.score,
+ divisionPlace: entry.divisionRank ?? 0,
+ globalPlace: entry.globalRank ?? null,
+ avatarUrl: info?.avatarUrl ?? null,
+ countryCode: info?.countryCode ?? null,
+ statusText: info?.statusText ?? null,
+ solves: Array.from(userSolves.get(entry.id) ?? []).map(solve => ({
+ id: solve.challengeId,
+ solveTime: solve.solveTime,
+ })),
+ dynamicScores: dynamicScores.get(entry.id) ?? [],
+ }
+ })
+}
+
+export const searchLeaderboard = async (
+ db: DatabaseClient,
+ search: string,
+ limit: number,
+ offset: number,
+ division?: string
+) => {
+ const searchFilter = userNameSearchFilter(search)
+
+ const whereClause = and(
+ searchFilter,
+ userIsPublicRankedSql,
+ division ? eq(users.division, division) : undefined
+ )
+
+ const [totalRow, matchingUsers] = await Promise.all([
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(users)
+ .where(whereClause)
+ .then(takeUnique),
+ db
+ .select(leaderboardEntrySelection)
+ .from(users)
+ .where(whereClause)
+ .orderBy(
+ sql`similarity(${users.name}, ${search}) DESC`,
+ asc(users.createdAt)
+ )
+ .limit(limit)
+ .offset(offset),
+ ])
+
+ return {
+ total: totalRow?.count ?? 0,
+ leaderboard: await hydrateLeaderboardEntries(db, matchingUsers),
+ }
+}
+
+const divisionWhereClause = and(
+ userIsPublicRankedSql,
+ eq(users.division, sql.placeholder('division'))
+)
+
+const preparedLeaderboardQueries = preparedPerDb(db => ({
+ count: db
+ .select({ count: sql`count(*)::int` })
+ .from(users)
+ .where(userIsPublicRankedSql)
+ .prepare('rctf_leaderboard_count'),
+ divisionCount: db
+ .select({ count: sql`count(*)::int` })
+ .from(users)
+ .where(divisionWhereClause)
+ .prepare('rctf_leaderboard_count_division'),
+ page: db
+ .select(leaderboardEntrySelection)
+ .from(users)
+ .where(userIsPublicRankedSql)
+ .orderBy(leaderboardOrderSql)
+ .limit(sql.placeholder('limit'))
+ .offset(sql.placeholder('offset'))
+ .prepare('rctf_leaderboard_page'),
+ divisionPage: db
+ .select(leaderboardEntrySelection)
+ .from(users)
+ .where(divisionWhereClause)
+ .orderBy(leaderboardOrderSql)
+ .limit(sql.placeholder('limit'))
+ .offset(sql.placeholder('offset'))
+ .prepare('rctf_leaderboard_page_division'),
+}))
+
+export const getLeaderboardWithTotal = async (
+ db: DatabaseClient,
+ limit: number,
+ offset: number,
+ division?: string
+) => {
+ const prepared = preparedLeaderboardQueries(db)
+
+ const [totalRow, leaderboard] = await Promise.all([
+ (division
+ ? prepared.divisionCount.execute({ division })
+ : prepared.count.execute()
+ ).then(takeUnique),
+ limit > 0
+ ? division
+ ? prepared.divisionPage.execute({ division, limit, offset })
+ : prepared.page.execute({ limit, offset })
+ : Promise.resolve([]),
+ ])
+
+ return {
+ total: totalRow?.count ?? 0,
+ leaderboard: await hydrateLeaderboardEntries(db, leaderboard),
+ }
+}
+
+export const getChallengeLeaderboardWithTotal = async (
+ db: DatabaseClient,
+ challengeId: string,
+ limit: number,
+ offset: number,
+ division?: string,
+ search?: string
+) => {
+ const challenge = await getChallenge(db, challengeId)
+ if (!challenge) {
+ return { total: 0, leaderboard: [] }
+ }
+
+ const whereClause = and(
+ userIsPublicRankedSql,
+ division ? eq(users.division, division) : undefined,
+ search ? userNameSearchFilter(search) : undefined
+ )
+ const solveJoin = and(
+ eq(solves.userid, users.id),
+ eq(solves.challengeid, challengeId)
+ )
+ const orderBy =
+ scoringKindOf(challenge.data) === ChallengeScoringKind.DYNAMIC
+ ? [desc(solves.points), asc(solves.pointsUpdatedAt), asc(solves.userid)]
+ : [asc(solves.createdat), asc(solves.userid)]
+
+ const [totalRow, leaderboard] = await Promise.all([
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(users)
+ .innerJoin(solves, solveJoin)
+ .where(whereClause)
+ .then(takeUnique),
+ db
+ .select(leaderboardEntrySelection)
+ .from(users)
+ .innerJoin(solves, solveJoin)
+ .where(whereClause)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset),
+ ])
+
+ return {
+ total: totalRow?.count ?? 0,
+ leaderboard: await hydrateLeaderboardEntries(db, leaderboard),
+ }
+}
+
+type LeaderboardFilters = {
+ limit: number
+ offset: number
+ division?: string
+ search?: string
+ challenge?: string
+}
+
+export const getLeaderboardWithFilters = (
+ db: DatabaseClient,
+ { limit, offset, division, search, challenge }: LeaderboardFilters
+) => {
+ if (challenge) {
+ return getChallengeLeaderboardWithTotal(
+ db,
+ challenge,
+ limit,
+ offset,
+ division,
+ search
+ )
+ }
+
+ if (search) {
+ return searchLeaderboard(db, search, limit, offset, division)
+ }
+
+ return getLeaderboardWithTotal(db, limit, offset, division)
+}
diff --git a/apps/api/src/services/solve-points.ts b/apps/api/src/services/solve-points.ts
index ffb4ae8f1..b3b9dcd81 100644
--- a/apps/api/src/services/solve-points.ts
+++ b/apps/api/src/services/solve-points.ts
@@ -11,15 +11,15 @@ import { and, asc, eq, sql } from 'drizzle-orm'
import type { PinoLogger } from 'hono-pino'
import type { TypedRedis } from '../cache/scripts'
import { inJsonbArray, insertInChunks } from '../lib/db-bulk'
-import { scoreProvider } from '../providers'
+import { scoreProvider } from '../providers/instances/score'
import { requestChallengeRecompute } from '../workers'
+import { userIsNotBanned } from './challenge-queries'
import {
getDecayChallenge,
getDecayChallenges,
getMaxSolveCount,
getPrivateChallenge,
lockChallenge,
- userIsNotBanned,
type DecayChallenge,
} from './challenges'
import { getCompetitionTiming, type CompetitionTiming } from './settings'
diff --git a/apps/api/src/services/users.ts b/apps/api/src/services/users.ts
index b957eb4ac..35bd55d92 100644
--- a/apps/api/src/services/users.ts
+++ b/apps/api/src/services/users.ts
@@ -44,7 +44,7 @@ import { setFilter } from '../lib/db-filters'
import { preparedPerDb } from '../lib/prepared'
import { createToken, TokenKind } from '../lib/tokens'
import { forceLeaderboardUpdate, requestChallengeRecompute } from '../workers'
-import { isDecayKind } from './challenges'
+import { isDecayKind } from './challenge-queries'
import { getCompetitionTiming } from './settings'
import {
emitBanScoreEvents,
diff --git a/apps/api/src/util/captcha.ts b/apps/api/src/util/captcha.ts
index 675971300..0f5a5bcf5 100644
--- a/apps/api/src/util/captcha.ts
+++ b/apps/api/src/util/captcha.ts
@@ -1,6 +1,6 @@
import { config } from '@rctf/config'
import type { ProtectedAction } from '@rctf/types'
-import { captchaProvider } from '../providers'
+import { captchaProvider } from '../providers/instances/captcha'
export const isActionProtected = (action: ProtectedAction): boolean => {
return config.captcha?.protectedEndpoints?.includes(action) ?? false
diff --git a/apps/api/src/workers/leaderboard-runner.ts b/apps/api/src/workers/leaderboard-runner.ts
index 733020783..3f53340c5 100644
--- a/apps/api/src/workers/leaderboard-runner.ts
+++ b/apps/api/src/workers/leaderboard-runner.ts
@@ -1,7 +1,7 @@
import type { DatabaseClient } from '@rctf/db'
import type { CalculatedLeaderboard } from '../cache/leaderboard'
import type { TypedRedis } from '../cache/scripts'
-import type { CachedLeaderboardComputation } from '../services/leaderboard'
+import type { CachedLeaderboardComputation } from '../services/leaderboard-calculation'
type Calculator = (db: DatabaseClient) => Promise
diff --git a/apps/api/src/workers/leaderboard.ts b/apps/api/src/workers/leaderboard.ts
index 1bc2220c3..f38e16a4c 100644
--- a/apps/api/src/workers/leaderboard.ts
+++ b/apps/api/src/workers/leaderboard.ts
@@ -3,7 +3,7 @@ import { ADVISORY_LOCK_KEYS, createDatabase } from '@rctf/db'
import { pino } from 'pino'
import { cacheLeaderboardAndGraph } from '../cache/leaderboard'
import { getMaxSolveCount } from '../services/challenges'
-import { createCachedLeaderboardCalculator } from '../services/leaderboard'
+import { createCachedLeaderboardCalculator } from '../services/leaderboard-calculation'
import {
applyDecayPointsForAllChallenges,
applyDecayPointsForChallenge,
diff --git a/apps/cli/src/commands/deployment/generate-csp.ts b/apps/cli/src/commands/deployment/generate-csp.ts
index 17b1b744e..0088b9a44 100644
--- a/apps/cli/src/commands/deployment/generate-csp.ts
+++ b/apps/cli/src/commands/deployment/generate-csp.ts
@@ -1,4 +1,4 @@
-import '@rctf/api/src/providers'
+import '@rctf/api/src/providers/instances/all'
import { registeredProviders, type Csp } from '@rctf/api/src/providers/base'
import { config } from '@rctf/config'
import { join } from 'path'
diff --git a/apps/cli/src/commands/seed/data.ts b/apps/cli/src/commands/seed/data.ts
index 35b7c6cbb..f9994d30f 100644
--- a/apps/cli/src/commands/seed/data.ts
+++ b/apps/cli/src/commands/seed/data.ts
@@ -1,4 +1,4 @@
-import { scoreProvider } from '@rctf/api/src/providers'
+import { scoreProvider } from '@rctf/api/src/providers/instances/score'
import {
DynamicFlagMode,
mintDynamicFlag,
diff --git a/apps/cli/src/commands/seed/run.ts b/apps/cli/src/commands/seed/run.ts
index 28d7cfaf8..4b872fe41 100644
--- a/apps/cli/src/commands/seed/run.ts
+++ b/apps/cli/src/commands/seed/run.ts
@@ -1,7 +1,7 @@
import { cacheLeaderboardAndGraph } from '@rctf/api/src/cache/leaderboard'
import { insertInChunks } from '@rctf/api/src/lib/db-bulk'
import { createToken, TokenKind } from '@rctf/api/src/lib/tokens'
-import { calculateLeaderboard } from '@rctf/api/src/services/leaderboard'
+import { calculateLeaderboard } from '@rctf/api/src/services/leaderboard-calculation'
import { config } from '@rctf/config'
import {
challenges,
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
index fb5d17d3a..cee884e63 100644
--- a/packages/config/src/index.ts
+++ b/packages/config/src/index.ts
@@ -1,9 +1,14 @@
import deepMerge from 'deepmerge'
import { loadEnvConfig, loadFileConfigs } from './loader'
+import { normalizeConfig } from './normalize'
import { ServerConfigSchema } from './types'
export * from './env'
+export * from './normalize'
export * from './types'
-export const config = ServerConfigSchema.parse(
- deepMerge.all([...loadFileConfigs(), loadEnvConfig()])
+
+export const config = normalizeConfig(
+ ServerConfigSchema.parse(
+ deepMerge.all([...loadFileConfigs(), loadEnvConfig()])
+ )
)
diff --git a/packages/config/src/normalize.ts b/packages/config/src/normalize.ts
new file mode 100644
index 000000000..403ed3877
--- /dev/null
+++ b/packages/config/src/normalize.ts
@@ -0,0 +1,28 @@
+import type { ServerConfig } from './types'
+
+// backporting v1 configs
+export const normalizeConfig = (config: ServerConfig): ServerConfig => {
+ if (!config.captcha?.provider && config.recaptcha) {
+ config.captcha = {
+ provider: {
+ name: 'captcha/recaptcha',
+ options: {
+ secretKey: config.recaptcha.secretKey,
+ siteKey: config.recaptcha.siteKey,
+ },
+ },
+ protectedEndpoints: config.recaptcha.protectedActions ?? [],
+ }
+ }
+
+ if (!config.analytics?.provider && config.globalSiteTag) {
+ config.analytics = {
+ provider: {
+ name: 'analytics/google',
+ options: { siteTag: config.globalSiteTag },
+ },
+ }
+ }
+
+ return config
+}
diff --git a/tests/server/setup.ts b/tests/server/setup.ts
index 826f0fbe1..c924b1516 100644
--- a/tests/server/setup.ts
+++ b/tests/server/setup.ts
@@ -119,9 +119,12 @@ mock.module('@rctf/db/util', () => {
mock.module('@rctf/config', () => {
const { loadFileConfigs } = require('../../packages/config/src/loader')
+ const { normalizeConfig } = require('../../packages/config/src/normalize')
const { ServerConfigSchema } = require('../../packages/config/src/types')
const env = require('../../packages/config/src/env')
- const config = ServerConfigSchema.parse(loadFileConfigs(testConfigDir)[0])
+ const config = normalizeConfig(
+ ServerConfigSchema.parse(loadFileConfigs(testConfigDir)[0])
+ )
return { ...env, config }
})
diff --git a/tests/server/tests/integration/admin-users.test.ts b/tests/server/tests/integration/admin-users.test.ts
index cbfa8c4d2..991bfb756 100644
--- a/tests/server/tests/integration/admin-users.test.ts
+++ b/tests/server/tests/integration/admin-users.test.ts
@@ -20,7 +20,7 @@ import { eq } from 'drizzle-orm'
import type { Hono } from 'hono'
import { cacheLeaderboardAndGraph } from '../../../../apps/api/src/cache/leaderboard'
import { resendPendingTeamVerification } from '../../../../apps/api/src/services/admin-verifications'
-import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard'
+import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation'
import {
createPendingRegistrationVerification,
getPendingRegistrationVerification,
diff --git a/tests/server/tests/integration/challenge-visibility.test.ts b/tests/server/tests/integration/challenge-visibility.test.ts
index 57741b9b6..d07c46dcb 100644
--- a/tests/server/tests/integration/challenge-visibility.test.ts
+++ b/tests/server/tests/integration/challenge-visibility.test.ts
@@ -15,7 +15,7 @@ import {
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { eq } from 'drizzle-orm'
import type { Hono } from 'hono'
-import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard'
+import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation'
import { getApp, request } from '../../app'
import {
expectResponse,
diff --git a/tests/server/tests/integration/dynamic-scoring.test.ts b/tests/server/tests/integration/dynamic-scoring.test.ts
index 509436c81..64609a5d3 100644
--- a/tests/server/tests/integration/dynamic-scoring.test.ts
+++ b/tests/server/tests/integration/dynamic-scoring.test.ts
@@ -20,7 +20,7 @@ import {
getMaxSolveCount,
upsertChallenge,
} from '../../../../apps/api/src/services/challenges'
-import { getChallengeLeaderboardWithTotal } from '../../../../apps/api/src/services/leaderboard'
+import { getChallengeLeaderboardWithTotal } from '../../../../apps/api/src/services/leaderboard-queries'
import {
applyDecayPointsForChallenge,
upsertDynamicSolves as rawUpsertDynamicSolves,
diff --git a/tests/server/tests/integration/leaderboard-cache.test.ts b/tests/server/tests/integration/leaderboard-cache.test.ts
index 2f5a5ef03..a76b609cb 100644
--- a/tests/server/tests/integration/leaderboard-cache.test.ts
+++ b/tests/server/tests/integration/leaderboard-cache.test.ts
@@ -18,11 +18,11 @@ import { afterEach, describe, expect, mock, test } from 'bun:test'
import { eq } from 'drizzle-orm'
import type { PinoLogger } from 'hono-pino'
import type { TypedRedis } from '../../../../apps/api/src/cache/scripts'
-import { scoreProvider } from '../../../../apps/api/src/providers'
+import { scoreProvider } from '../../../../apps/api/src/providers/instances/score'
import {
calculateLeaderboard,
createCachedLeaderboardCalculator,
-} from '../../../../apps/api/src/services/leaderboard'
+} from '../../../../apps/api/src/services/leaderboard-calculation'
import {
applyChallengeConfigChange,
applyDecayPointsForAllChallenges,
diff --git a/tests/server/tests/integration/leaderboard-e2e.test.ts b/tests/server/tests/integration/leaderboard-e2e.test.ts
index c2c1ddec5..8ee14954c 100644
--- a/tests/server/tests/integration/leaderboard-e2e.test.ts
+++ b/tests/server/tests/integration/leaderboard-e2e.test.ts
@@ -18,7 +18,7 @@ import { eq } from 'drizzle-orm'
import type { Hono } from 'hono'
import { cacheLeaderboardAndGraph } from '../../../../apps/api/src/cache/leaderboard'
import { createToken, TokenKind } from '../../../../apps/api/src/lib/tokens'
-import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard'
+import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation'
import { createRedis } from '../../../../apps/api/src/util/redis'
import { getApp, request } from '../../app'
import {
diff --git a/tests/server/tests/integration/leaderboard-global-place.test.ts b/tests/server/tests/integration/leaderboard-global-place.test.ts
index 6c89760ef..a8fb838db 100644
--- a/tests/server/tests/integration/leaderboard-global-place.test.ts
+++ b/tests/server/tests/integration/leaderboard-global-place.test.ts
@@ -14,10 +14,10 @@ import {
} from '../../../../apps/api/src/cache/leaderboard'
import { getFullUser } from '../../../../apps/api/src/services/full-user'
import {
- calculateLeaderboard,
getLeaderboardWithTotal,
searchLeaderboard,
-} from '../../../../apps/api/src/services/leaderboard'
+} from '../../../../apps/api/src/services/leaderboard-queries'
+import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation'
import { createRedis } from '../../../../apps/api/src/util/redis'
import { clearDatabase } from '../../util'
diff --git a/tests/server/tests/integration/search.test.ts b/tests/server/tests/integration/search.test.ts
index d819945f9..da4888cf7 100644
--- a/tests/server/tests/integration/search.test.ts
+++ b/tests/server/tests/integration/search.test.ts
@@ -22,7 +22,7 @@ import { eq } from 'drizzle-orm'
import type { Hono } from 'hono'
import { cacheLeaderboardAndGraph } from '../../../../apps/api/src/cache/leaderboard'
import { createToken, TokenKind } from '../../../../apps/api/src/lib/tokens'
-import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard'
+import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation'
import { createRedis } from '../../../../apps/api/src/util/redis'
import { getApp, request } from '../../app'
import { expectResponse, generateChallenge } from '../../util'
diff --git a/tests/server/tests/unit/leaderboard-recompute.test.ts b/tests/server/tests/unit/leaderboard-recompute.test.ts
index 2e3305672..e7e45a7e6 100644
--- a/tests/server/tests/unit/leaderboard-recompute.test.ts
+++ b/tests/server/tests/unit/leaderboard-recompute.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
-import { scoreProvider } from '../../../../apps/api/src/providers'
+import { scoreProvider } from '../../../../apps/api/src/providers/instances/score'
import { createRecomputeQueue } from '../../../../apps/api/src/workers/leaderboard-recompute'
const setRequiredFields = (fields: ReadonlyArray) => {
diff --git a/tests/server/tests/unit/provider-instances-all.test.ts b/tests/server/tests/unit/provider-instances-all.test.ts
new file mode 100644
index 000000000..877406cb7
--- /dev/null
+++ b/tests/server/tests/unit/provider-instances-all.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from 'bun:test'
+import { readdirSync, readFileSync } from 'node:fs'
+import path from 'node:path'
+
+const instancesDir = path.resolve(
+ import.meta.dir,
+ '../../../../apps/api/src/providers/instances'
+)
+
+describe('provider instances', () => {
+ test('all.ts imports every instance module', () => {
+ const source = readFileSync(path.join(instancesDir, 'all.ts'), 'utf8')
+ const modules = readdirSync(instancesDir).filter(
+ name => name.endsWith('.ts') && name !== 'all.ts' && name !== 'load.ts'
+ )
+
+ expect(modules.length).toBeGreaterThan(0)
+ for (const name of modules) {
+ expect(source).toContain(`'./${name.replace(/\.ts$/, '')}'`)
+ }
+ })
+})