diff --git a/packages/api/.env.example b/packages/api/.env.example index 3570f3ea..83bc522a 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -5,8 +5,8 @@ NETWORK_CHAIN_WSS_URL = "" DATABASE_URL = "" DIRECT_URL = "" CMC_API_KEY = "" -EE_AUTH_TOKEN = "" + +# Optional for dev environments. If ommitted, API Token restrictions are lifted and no comms with dev-portal DB. SUPABASE_SERVICE_ROLE_KEY = "" -SUPABASE_FETCH_ALL_USERS_URL = "https://.supabase.co/functions/v1/fetch-all-users" -SUPABASE_FETCH_ACCESS_LEVEL_URL = "https://.supabase.co/functions/v1/fetch-access-level" -SUPABASE_POST_REQUESTS_URL = "https://.supabase.co/functions/v1/post-requests" \ No newline at end of file +SUPABASE_PROJECT_REF = "" +SUPABASE_EF_SELECTORS = "a:b:c" \ No newline at end of file diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 547fb009..3f472108 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -7,7 +7,10 @@ import logger from 'morgan' import helmet from 'helmet' import cors from 'cors' import apiRouter from './routes' +import { requestsStore } from './utils/authCache' +import { triggerUserRequestsSync } from './utils/requestsUpdateManager' import { EigenExplorerApiError, handleAndReturnErrorResponse } from './schema/errors' +import { isAuthRequired, refreshAuthStore } from './utils/authMiddleware' const PORT = process.env.PORT ? Number.parseInt(process.env.PORT) : 3002 @@ -22,12 +25,34 @@ app.use(express.json()) app.use(express.urlencoded({ extended: false })) app.use(cookieParser()) +// Route cost increment in cache for caller's API Token +if (isAuthRequired()) { + refreshAuthStore() + app.use((req, res, next) => { + res.on('finish', () => { + try { + if (res.statusCode >= 200 && res.statusCode < 300) { + const apiToken = req.header('X-API-Token') + if (apiToken) { + const key = `apiToken:${apiToken}:newRequests` + const currentCalls: number = requestsStore.get(key) || 0 + const cost = req.cost || 1 + requestsStore.set(key, currentCalls + cost) + triggerUserRequestsSync(apiToken) + } + } + } catch {} + }) + next() + }) +} + // Routes app.use('/', apiRouter) app.get('/favicon.ico', (req, res) => res.sendStatus(204)) -// catch 404 and forward to error handler +// Catch 404 and forward to error handler app.use((req, res) => { const err = new EigenExplorerApiError({ code: 'not_found', @@ -36,13 +61,13 @@ app.use((req, res) => { handleAndReturnErrorResponse(req, res, err) }) -// error handler +// Error handler app.use((err: Error, req: Request, res: Response) => { - // set locals, only providing error in development + // Set locals, only providing error in development res.locals.message = err.message res.locals.error = req.app.get('env') === 'development' ? err : {} - // render the error page + // Render the error page res.status(500) res.render('error') }) diff --git a/packages/api/src/routes/auth/authController.ts b/packages/api/src/routes/auth/authController.ts index b4b0827b..7f130bbc 100644 --- a/packages/api/src/routes/auth/authController.ts +++ b/packages/api/src/routes/auth/authController.ts @@ -1,8 +1,7 @@ import type { Request, Response } from 'express' -import { handleAndReturnErrorResponse } from '../../schema/errors' +import { EigenExplorerApiError, handleAndReturnErrorResponse } from '../../schema/errors' import { EthereumAddressSchema } from '../../schema/zod/schemas/base/ethereumAddress' -import { refreshAuthStore } from '../../utils/authMiddleware' -import { RegisterUserBodySchema, RequestHeadersSchema } from '../../schema/zod/schemas/auth' +import { RegisterUserBodySchema } from '../../schema/zod/schemas/auth' import { verifyMessage } from 'viem' import prisma from '../../utils/prismaClient' import crypto from 'node:crypto' @@ -16,22 +15,16 @@ import crypto from 'node:crypto' * @returns */ export async function checkUserStatus(req: Request, res: Response) { - const headerCheck = RequestHeadersSchema.safeParse(req.headers) - if (!headerCheck.success) { - return handleAndReturnErrorResponse(req, res, headerCheck.error) - } - const paramCheck = EthereumAddressSchema.safeParse(req.params.address) if (!paramCheck.success) { return handleAndReturnErrorResponse(req, res, paramCheck.error) } try { - const apiToken = headerCheck.data['X-API-Token'] - const authToken = process.env.EE_AUTH_TOKEN + const accessLevel = req.accessLevel || 0 - if (!apiToken || apiToken !== authToken) { - throw new Error('Unauthorized access.') + if (accessLevel !== 999) { + throw new EigenExplorerApiError({ code: 'unauthorized', message: 'Unauthorized access.' }) } const { address } = req.params @@ -64,17 +57,11 @@ export async function checkUserStatus(req: Request, res: Response) { * @returns */ export async function generateNonce(req: Request, res: Response) { - const headerCheck = RequestHeadersSchema.safeParse(req.headers) - if (!headerCheck.success) { - return handleAndReturnErrorResponse(req, res, headerCheck.error) - } - try { - const apiToken = headerCheck.data['X-API-Token'] - const authToken = process.env.EE_AUTH_TOKEN + const accessLevel = req.accessLevel || 0 - if (!apiToken || apiToken !== authToken) { - throw new Error('Unauthorized access.') + if (accessLevel !== 999) { + throw new EigenExplorerApiError({ code: 'unauthorized', message: 'Unauthorized access.' }) } const nonce = `0x${crypto.randomBytes(32).toString('hex')}` @@ -94,11 +81,6 @@ export async function generateNonce(req: Request, res: Response) { * @returns */ export async function registerUser(req: Request, res: Response) { - const headerCheck = RequestHeadersSchema.safeParse(req.headers) - if (!headerCheck.success) { - return handleAndReturnErrorResponse(req, res, headerCheck.error) - } - const paramCheck = EthereumAddressSchema.safeParse(req.params.address) if (!paramCheck.success) { return handleAndReturnErrorResponse(req, res, paramCheck.error) @@ -110,11 +92,10 @@ export async function registerUser(req: Request, res: Response) { } try { - const apiToken = headerCheck.data['X-API-Token'] - const authToken = process.env.EE_AUTH_TOKEN + const accessLevel = req.accessLevel || 0 - if (!apiToken || apiToken !== authToken) { - throw new Error('Unauthorized access.') + if (accessLevel !== 999) { + throw new EigenExplorerApiError({ code: 'unauthorized', message: 'Unauthorized access.' }) } const { address } = req.params @@ -150,37 +131,3 @@ export async function registerUser(req: Request, res: Response) { handleAndReturnErrorResponse(req, res, error) } } - -/** - * Protected route, refreshes the server's entire auth store. Called by Supabase edge fn signal-refresh - * This function will fail if the caller does not use admin-level auth token - * - * @param req - * @param res - * @returns - */ -export async function signalRefreshAuthStore(req: Request, res: Response) { - const headerCheck = RequestHeadersSchema.safeParse(req.headers) - if (!headerCheck.success) { - return handleAndReturnErrorResponse(req, res, headerCheck.error) - } - - try { - const apiToken = headerCheck.data['X-API-Token'] - const authToken = process.env.EE_AUTH_TOKEN - - if (!apiToken || apiToken !== authToken) { - throw new Error('Unauthorized access.') - } - - const status = await refreshAuthStore() - - if (!status) { - throw new Error('Refresh auth store failed.') - } - - res.status(200).json({ message: 'Auth store refreshed.' }) - } catch (error) { - handleAndReturnErrorResponse(req, res, error) - } -} diff --git a/packages/api/src/routes/auth/authRoutes.ts b/packages/api/src/routes/auth/authRoutes.ts index 8817e995..dbf3bbbf 100644 --- a/packages/api/src/routes/auth/authRoutes.ts +++ b/packages/api/src/routes/auth/authRoutes.ts @@ -1,13 +1,11 @@ import express from 'express' import routeCache from 'route-cache' -import { signalRefreshAuthStore } from './authController' import { checkUserStatus, generateNonce, registerUser } from './authController' const router = express.Router() // API routes for /auth -router.post('/refresh-store', signalRefreshAuthStore) router.get('/users/:address/check-status', routeCache.cacheSeconds(30), checkUserStatus) router.get('/users/:address/nonce', routeCache.cacheSeconds(10), generateNonce) router.post('/users/:address/register', routeCache.cacheSeconds(10), registerUser) diff --git a/packages/api/src/routes/avs/avsController.ts b/packages/api/src/routes/avs/avsController.ts index ee24b4b7..409a11bb 100644 --- a/packages/api/src/routes/avs/avsController.ts +++ b/packages/api/src/routes/avs/avsController.ts @@ -8,9 +8,8 @@ import { UpdatedSinceQuerySchema } from '../../schema/zod/schemas/updatedSinceQu import { SortByQuerySchema } from '../../schema/zod/schemas/sortByQuery' import { SearchByTextQuerySchema } from '../../schema/zod/schemas/searchByTextQuery' import { WithRewardsQuerySchema } from '../../schema/zod/schemas/withRewardsQuery' -import { RequestHeadersSchema } from '../../schema/zod/schemas/auth' import { getOperatorSearchQuery } from '../operators/operatorController' -import { handleAndReturnErrorResponse } from '../../schema/errors' +import { EigenExplorerApiError, handleAndReturnErrorResponse } from '../../schema/errors' import { getStrategiesWithShareUnderlying, sharesToTVL, @@ -720,17 +719,11 @@ export async function invalidateMetadata(req: Request, res: Response) { return handleAndReturnErrorResponse(req, res, paramCheck.error) } - const headerCheck = RequestHeadersSchema.safeParse(req.headers) - if (!headerCheck.success) { - return handleAndReturnErrorResponse(req, res, headerCheck.error) - } - try { - const apiToken = headerCheck.data['X-API-Token'] - const authToken = process.env.EE_AUTH_TOKEN + const accessLevel = req.accessLevel || 0 - if (!apiToken || apiToken !== authToken) { - throw new Error('Unauthorized access.') + if (accessLevel !== 999) { + throw new EigenExplorerApiError({ code: 'unauthorized', message: 'Unauthorized access.' }) } const { address } = req.params diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index 0f8e24ae..98d90f1d 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,4 +1,4 @@ -import express from 'express' +import express, { type Router } from 'express' import avsRoutes from './avs/avsRoutes' import strategiesRoutes from './strategies/strategiesRoutes' import operatorRoutes from './operators/operatorRoutes' @@ -10,10 +10,14 @@ import auxiliaryRoutes from './auxiliary/auxiliaryRoutes' import rewardRoutes from './rewards/rewardRoutes' import eventRoutes from './events/eventsRoutes' import authRoutes from './auth/authRoutes' -import { authenticator, rateLimiter } from '../utils/authMiddleware' +import { authenticator, isAuthRequired, rateLimiter } from '../utils/authMiddleware' const apiRouter = express.Router() +const setMiddleware = (router: Router) => { + return isAuthRequired() ? [authenticator, rateLimiter, router] : [router] +} + // Health route apiRouter.get('/health', (_, res) => res.send({ status: 'ok' })) @@ -23,16 +27,22 @@ apiRouter.get('/version', (_, res) => ) // Remaining routes -apiRouter.use('/avs', authenticator, rateLimiter, avsRoutes) -apiRouter.use('/strategies', authenticator, rateLimiter, strategiesRoutes) -apiRouter.use('/operators', authenticator, rateLimiter, operatorRoutes) -apiRouter.use('/stakers', authenticator, rateLimiter, stakerRoutes) -apiRouter.use('/metrics', authenticator, rateLimiter, metricRoutes) -apiRouter.use('/withdrawals', authenticator, rateLimiter, withdrawalRoutes) -apiRouter.use('/deposits', authenticator, rateLimiter, depositRoutes) -apiRouter.use('/aux', authenticator, rateLimiter, auxiliaryRoutes) -apiRouter.use('/rewards', authenticator, rateLimiter, rewardRoutes) -apiRouter.use('/events', authenticator, rateLimiter, eventRoutes) -apiRouter.use('/auth', authenticator, rateLimiter, authRoutes) +const routes = { + '/avs': avsRoutes, + '/strategies': strategiesRoutes, + '/operators': operatorRoutes, + '/stakers': stakerRoutes, + '/metrics': metricRoutes, + '/withdrawals': withdrawalRoutes, + '/deposits': depositRoutes, + '/aux': auxiliaryRoutes, + '/rewards': rewardRoutes, + '/events': eventRoutes, + '/auth': authRoutes +} + +for (const [path, router] of Object.entries(routes)) { + apiRouter.use(path, ...setMiddleware(router)) +} export default apiRouter diff --git a/packages/api/src/routes/operators/operatorController.ts b/packages/api/src/routes/operators/operatorController.ts index c0468133..9a81bcee 100644 --- a/packages/api/src/routes/operators/operatorController.ts +++ b/packages/api/src/routes/operators/operatorController.ts @@ -10,8 +10,7 @@ import { OperatorDelegationEventQuerySchema, OperatorRegistrationEventQuerySchema } from '../../schema/zod/schemas/eventSchemas' -import { RequestHeadersSchema } from '../../schema/zod/schemas/auth' -import { handleAndReturnErrorResponse } from '../../schema/errors' +import { EigenExplorerApiError, handleAndReturnErrorResponse } from '../../schema/errors' import { getStrategiesWithShareUnderlying, sharesToTVL, @@ -485,17 +484,11 @@ export async function invalidateMetadata(req: Request, res: Response) { return handleAndReturnErrorResponse(req, res, paramCheck.error) } - const headerCheck = RequestHeadersSchema.safeParse(req.headers) - if (!headerCheck.success) { - return handleAndReturnErrorResponse(req, res, headerCheck.error) - } - try { - const apiToken = headerCheck.data['X-API-Token'] - const authToken = process.env.EE_AUTH_TOKEN + const accessLevel = req.accessLevel || 0 - if (!apiToken || apiToken !== authToken) { - throw new Error('Unauthorized access.') + if (accessLevel !== 999) { + throw new EigenExplorerApiError({ code: 'unauthorized', message: 'Unauthorized access.' }) } const { address } = req.params diff --git a/packages/api/src/schema/zod/schemas/auth.ts b/packages/api/src/schema/zod/schemas/auth.ts index 3db41c68..c283f268 100644 --- a/packages/api/src/schema/zod/schemas/auth.ts +++ b/packages/api/src/schema/zod/schemas/auth.ts @@ -1,18 +1,5 @@ import z from '..' -export const RequestHeadersSchema = z - .object({ - 'x-api-token': z.string().optional() - }) - .transform((headers) => { - const token = Object.keys(headers).find((key) => key.toLowerCase() === 'x-api-token') - return token - ? { - 'X-API-Token': headers[token] - } - : {} - }) - export const RegisterUserBodySchema = z.object({ signature: z.string().startsWith('0x').length(132), nonce: z.string().startsWith('0x').length(66) diff --git a/packages/api/src/utils/authCache.ts b/packages/api/src/utils/authCache.ts index 807e412a..20fa81b6 100644 --- a/packages/api/src/utils/authCache.ts +++ b/packages/api/src/utils/authCache.ts @@ -1,16 +1,13 @@ -import { refreshAuthStore } from './authMiddleware' import NodeCache from 'node-cache' /** - * Init cache that stores `accessLevel` & `accountRestricted` per api token. - * On server boot, load it up with all auth data from db. + * Cache that stores `accessLevel` & `accountRestricted` per API token * */ -export const authStore = new NodeCache({ stdTTL: 7 * 24 * 60 * 60 }) // 1 week -refreshAuthStore() +export const authStore = new NodeCache({ stdTTL: 60 * 60 }) // 1 hour /** - * Init cache that collects `newRequests` per api token. + * Cache that collects `newRequests` per API token * */ -export const requestsStore = new NodeCache({ stdTTL: 7 * 24 * 60 * 60 }) // 1 week +export const requestsStore = new NodeCache({ stdTTL: 24 * 60 * 60 }) // 1 day diff --git a/packages/api/src/utils/authMiddleware.ts b/packages/api/src/utils/authMiddleware.ts index 569b05e3..7fce5852 100644 --- a/packages/api/src/utils/authMiddleware.ts +++ b/packages/api/src/utils/authMiddleware.ts @@ -1,8 +1,7 @@ import 'dotenv/config' import type { NextFunction, Request, Response } from 'express' -import { authStore, requestsStore } from './authCache' -import { triggerUserRequestsSync } from './requestsUpdateManager' +import { authStore } from './authCache' import { EigenExplorerApiError, handleAndReturnErrorResponse } from '../schema/errors' import rateLimit from 'express-rate-limit' @@ -42,6 +41,49 @@ const PLANS: Record = { } } +// --- Rate Limiting --- + +/** + * Define rate limiters for plans where req/min is applicable + * + */ +const rateLimiters: Record> = {} +for (const [level, plan] of Object.entries(PLANS)) { + const accessLevel = Number(level) + + if (plan.requestsPerMin) { + rateLimiters[accessLevel] = rateLimit({ + windowMs: 1 * 60 * 1000, + max: plan.requestsPerMin, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request): string => req.key, + message: `You've reached the limit of ${plan.requestsPerMin} requests per minute. Upgrade your plan for increased limits.` + }) + } +} + +/** + * Return a rate limiter basis the caller's access level + * + * @param req + * @param res + * @param next + * @returns + */ +export const rateLimiter = (req: Request, res: Response, next: NextFunction) => { + const accessLevel = req.accessLevel + + // Skip rate limiting if req/min not applicable to plan + if (!PLANS[accessLevel].requestsPerMin) { + return next() + } + + // Apply rate limiting + const limiter = rateLimiters[accessLevel] + return limiter(req, res, next) +} + // --- Authentication --- /** @@ -62,9 +104,10 @@ const PLANS: Record = { */ export const authenticator = async (req: Request, res: Response, next: NextFunction) => { const apiToken = req.header('X-API-Token') + const edgeFunctionIndex = 2 let accessLevel: number - // Find access level & set rate limiting key + // Find access level if (!apiToken) { accessLevel = 0 } else { @@ -74,28 +117,37 @@ export const authenticator = async (req: Request, res: Response, next: NextFunct if (!updatedAt && !authStore.get('isRefreshing')) refreshAuthStore() const accountRestricted = authStore.get(`apiToken:${apiToken}:accountRestricted`) || 0 // Benefit of the doubt - if (process.env.EE_AUTH_TOKEN === apiToken) { - accessLevel = 999 - } else if (accountRestricted === 0) { - accessLevel = authStore.get(`apiToken:${apiToken}:accessLevel`) ?? 998 // Temp state, fallback to DB - } else { - accessLevel = -1 - } + accessLevel = + accountRestricted === 0 + ? authStore.get(`apiToken:${apiToken}:accessLevel`) ?? 998 // Temp state, fallback to DB + : -1 } + // In case of fallback, fetch access level from DB via edge function if (accessLevel === 998) { - const response = await fetch(`${process.env.SUPABASE_FETCH_ACCESS_LEVEL_URL}/${apiToken}`, { + const functionUrl = constructEfUrl(edgeFunctionIndex) + + if (!functionUrl) { + throw new Error('Invalid function selector') + } + + const response = await fetch(`${functionUrl}/${apiToken}`, { method: 'GET', headers: { Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`, 'Content-Type': 'application/json' } }) - const payload = await response.json() - accessLevel = response.ok ? Number(payload?.data?.accessLevel) : 1 // Benefit of the doubt + + if (response.ok) { + accessLevel = Number((await response.json()).data.accessLevel) + authStore.set(`apiToken:${apiToken}:accessLevel`, accessLevel) + } else { + accessLevel = 1 // Benefit of the doubt + } } - // Impose limits + // Impose limits if applicable if (accessLevel === 0) { const err = new EigenExplorerApiError({ code: 'unauthorized', @@ -107,84 +159,18 @@ export const authenticator = async (req: Request, res: Response, next: NextFunct if (accessLevel === -1) { const err = new EigenExplorerApiError({ - code: 'rate_limit_exceeded', + code: 'unauthorized', message: 'You have reached your monthly limit. Please contact us to increase limits.' }) return handleAndReturnErrorResponse(req, res, err) } + // Allow request req.accessLevel = accessLevel next() } -// --- Rate Limiting --- - -/** - * Create rate limiters for plans where req/min is applicable - * - */ - -const rateLimiters: Record> = {} - -for (const [level, plan] of Object.entries(PLANS)) { - const accessLevel = Number(level) - - if (plan.requestsPerMin) { - rateLimiters[accessLevel] = rateLimit({ - windowMs: 1 * 60 * 1000, - max: plan.requestsPerMin, - standardHeaders: true, - legacyHeaders: false, - keyGenerator: (req: Request): string => req.key, - message: `You've reached the limit of ${plan.requestsPerMin} requests per minute. Upgrade your plan for increased limits.` - }) - } -} - -/** - * Return a rate limiter basis the caller's access level - * - * @param req - * @param res - * @param next - * @returns - */ -export const rateLimiter = (req: Request, res: Response, next: NextFunction) => { - const accessLevel = req.accessLevel - - // Skip rate limiting if req/min not applicable to plan - if (!PLANS[accessLevel].requestsPerMin) { - return next() - } - - // Apply rate limiting - const limiter = rateLimiters[accessLevel] - - // Increment `requestsStore` for successful requests - const originalEnd = res.end - - // biome-ignore lint/suspicious/noExplicitAny: - res.end = function (chunk?: any, encoding?: any, cb?: any) { - try { - if (res.statusCode >= 200 && res.statusCode < 300) { - const apiToken = req.header('X-API-Token') - if (apiToken) { - const key = `apiToken:${apiToken}:newRequests` - const currentCalls: number = requestsStore.get(key) || 0 - requestsStore.set(key, currentCalls + 1) - triggerUserRequestsSync(apiToken) - } - } - } catch {} - - res.end = originalEnd - return originalEnd.call(this, chunk, encoding, cb) - } - - return limiter(req, res, next) -} - // --- Auth store management --- /** @@ -202,18 +188,22 @@ export async function refreshAuthStore() { let skip = 0 const take = 10_000 + const edgeFunctionIndex = 1 while (true) { - const response = await fetch( - `${process.env.SUPABASE_FETCH_ALL_USERS_URL}?skip=${skip}&take=${take}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`, - 'Content-Type': 'application/json' - } + const functionUrl = constructEfUrl(edgeFunctionIndex) + + if (!functionUrl) { + throw new Error('Invalid function selector') + } + + const response = await fetch(`${functionUrl}?skip=${skip}&take=${take}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`, + 'Content-Type': 'application/json' } - ) + }) if (!response.ok) { throw new Error() @@ -250,3 +240,35 @@ export async function refreshAuthStore() { authStore.set('isRefreshing', false) } } + +// --- Helper Functions --- + +/** + * Returns whether the deployment should impose auth (API Token restrictions, comms with dev-portal DB) + * + */ +export function isAuthRequired() { + return ( + process.env.SUPABASE_SERVICE_ROLE_KEY && + process.env.SUPABASE_PROJECT_REF && + process.env.SUPABASE_EF_SELECTORS + ) +} + +/** + * Returns URL of an Edge Function deployed on the dev-portal DB, given its function selector index + * 1 -> Fetching all users + * 2 -> Fetching access level for a given API token + * 3 -> Posting updates on # of new requests for a set of API tokens + * + * @param index + * @returns + */ +export function constructEfUrl(index: number) { + const projectRef = process.env.SUPABASE_PROJECT_REF || null + const functionSelector = process.env.SUPABASE_EF_SELECTORS?.split(':')[index - 1] || null + + return projectRef && functionSelector + ? `https://${projectRef}.supabase.co/functions/v1/${functionSelector}` + : null +} diff --git a/packages/api/src/utils/request.ts b/packages/api/src/utils/request.ts index 3d2421dc..9472d950 100644 --- a/packages/api/src/utils/request.ts +++ b/packages/api/src/utils/request.ts @@ -5,6 +5,7 @@ declare global { interface Request { accessLevel: number key: string + cost?: number } } } diff --git a/packages/api/src/utils/requestsUpdateManager.ts b/packages/api/src/utils/requestsUpdateManager.ts index a8b53dc4..519ce258 100644 --- a/packages/api/src/utils/requestsUpdateManager.ts +++ b/packages/api/src/utils/requestsUpdateManager.ts @@ -1,4 +1,5 @@ import { requestsStore } from './authCache' +import { refreshAuthStore, constructEfUrl } from './authMiddleware' interface UpdatePayload { key: string @@ -15,19 +16,18 @@ interface QueueState { } /** - * Manages DB updates for API Token request count + * Manages DB updates for API Token request count, utilizing `requestsStore` * */ class RequestsUpdateManager { private updateInterval = 60_000 // 1 minute + private edgeFunctionIndex = 3 private updateTimeout: NodeJS.Timeout | null = null private queue: QueueState = { current: new Map(), next: new Map() } - constructor(private readonly supabaseUrl: string, private readonly supabaseKey: string) {} - async queueUpdate(apiToken: string): Promise { const requestKey = `apiToken:${apiToken}:newRequests` const newRequests = Number(requestsStore.get(requestKey)) || 0 @@ -68,8 +68,14 @@ class RequestsUpdateManager { private async performUpdate(): Promise { try { if (this.queue.current.size > 0) { + const functionUrl = constructEfUrl(this.edgeFunctionIndex) const updatePayload = Array.from(this.queue.current.values()) - await this.httpClient(this.supabaseUrl, updatePayload) + + if (!functionUrl) { + throw new Error('Invalid function selector') + } + + await this.httpClient(functionUrl, updatePayload) // Clear processed requests from cache for (const payload of updatePayload) { @@ -82,6 +88,7 @@ class RequestsUpdateManager { } catch (error) { console.error('[Data] Update failed:', error) } finally { + refreshAuthStore() this.updateTimeout = null this.queue.current = this.queue.next this.queue.next = new Map() @@ -96,7 +103,7 @@ class RequestsUpdateManager { const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Bearer ${this.supabaseKey}`, + Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data.map((payload) => payload.data)) @@ -108,15 +115,10 @@ class RequestsUpdateManager { } } -const updateManager = new RequestsUpdateManager( - // biome-ignore lint/style/noNonNullAssertion: - process.env.SUPABASE_POST_REQUESTS_URL!, - // biome-ignore lint/style/noNonNullAssertion: - process.env.SUPABASE_SERVICE_ROLE_KEY! -) +const updateManager = new RequestsUpdateManager() /** - * Call this function after a request is received & API Token is identified + * Called at the end of every authenticated request, after `requestsStore` is incremented with the route cost * * @returns */ diff --git a/packages/api/src/utils/routeCost.ts b/packages/api/src/utils/routeCost.ts new file mode 100644 index 00000000..f141b15b --- /dev/null +++ b/packages/api/src/utils/routeCost.ts @@ -0,0 +1,15 @@ +import type { NextFunction, Request, Response } from 'express' + +/** + * Middleware function to be used when defining API routes and pointing them to their internal functions + * If not set, cost calculator after route completion will consider cost as 1 + * + * @param cost + * @returns + */ +export function routeCost(cost: number) { + return (req: Request, _res: Response, next: NextFunction) => { + req.cost = cost + next() + } +}