From 412d813717acab326d3ba34a0d3d50ce0db7e1ae Mon Sep 17 00:00:00 2001 From: Sergii Date: Thu, 12 Jun 2025 16:37:34 +0300 Subject: [PATCH 1/2] feat: adds feature flags support to console-api's notifications proxy --- apps/api/env/.env.functional.test | 3 +- apps/api/env/.env.local.sample | 3 +- apps/api/env/.env.production | 3 +- apps/api/env/.env.sample | 3 + apps/api/env/.env.unit.test | 4 +- apps/api/package.json | 3 +- apps/api/src/app.ts | 35 +++- apps/api/src/auth/services/auth.service.ts | 4 +- apps/api/src/core/config/env.config.ts | 47 +++-- .../core/services/config/config.service.ts | 6 +- .../core-config/core-config.service.ts | 4 +- .../execution-context.service.ts | 18 +- .../feature-flags.service.spec.ts | 161 ++++++++++++++++++ .../feature-flags/feature-flags.service.ts | 79 +++++++++ .../services/feature-flags/feature-flags.ts | 5 + .../routes/proxy/proxy.route.spec.ts | 6 +- .../notifications/routes/proxy/proxy.route.ts | 27 ++- .../user/controllers/user/user.controller.ts | 2 +- package-lock.json | 10 +- packages/docker/docker-compose.dev.yml | 40 +++++ 20 files changed, 417 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts create mode 100644 apps/api/src/core/services/feature-flags/feature-flags.service.ts create mode 100644 apps/api/src/core/services/feature-flags/feature-flags.ts diff --git a/apps/api/env/.env.functional.test b/apps/api/env/.env.functional.test index c7138d5baa..f1e9ac7ef9 100644 --- a/apps/api/env/.env.functional.test +++ b/apps/api/env/.env.functional.test @@ -26,7 +26,8 @@ FAUCET_URL=https://faucet.sandbox-01.aksh.pw/faucet API_NODE_ENDPOINT=https://api.sandbox-01.aksh.pw PROVIDER_PROXY_URL=https://console-provider-proxy.akash.network -ALLOW_ANONYMOUS_USER_TRIAL=true AMPLITUDE_API_KEY=AMPLITUDE_API_KEY AMPLITUDE_SAMPLING=1 NOTIFICATIONS_API_BASE_URL=http://localhost:3081 + +FEATURE_FLAGS_ENABLE_ALL=true diff --git a/apps/api/env/.env.local.sample b/apps/api/env/.env.local.sample index 6dae8f09be..78e0101739 100644 --- a/apps/api/env/.env.local.sample +++ b/apps/api/env/.env.local.sample @@ -32,8 +32,7 @@ PROVIDER_PROXY_URL=http://localhost:3040 DEPLOYMENT_ENV=local -ALLOW_ANONYMOUS_USER_TRIAL=true AMPLITUDE_API_KEY=AMPLITUDE_API_KEY AMPLITUDE_SAMPLING=1 -NOTIFICATIONS_API_BASE_URL=http://localhost:3081 \ No newline at end of file +NOTIFICATIONS_API_BASE_URL=http://localhost:3081 diff --git a/apps/api/env/.env.production b/apps/api/env/.env.production index bdb9a772ca..f5398dfaad 100644 --- a/apps/api/env/.env.production +++ b/apps/api/env/.env.production @@ -9,5 +9,4 @@ BILLING_ENABLED=true STRIPE_CHECKOUT_REDIRECT_URL=https://console.akash.network PROVIDER_PROXY_URL=https://console-provider-proxy.akash.network -ALLOW_ANONYMOUS_USER_TRIAL=false -AMPLITUDE_SAMPLING=1 \ No newline at end of file +AMPLITUDE_SAMPLING=1 diff --git a/apps/api/env/.env.sample b/apps/api/env/.env.sample index 6b14d58158..e90b4a33b3 100644 --- a/apps/api/env/.env.sample +++ b/apps/api/env/.env.sample @@ -36,3 +36,6 @@ WEBSITE_URL= AMPLITUDE_API_KEY= AMPLITUDE_SAMPLING= NOTIFICATIONS_API_BASE_URL= +FEATURE_FLAGS_ENABLE_ALL=true +UNLEASH_SERVER_API_TOKEN= +UNLEASH_SERVER_API_URL= diff --git a/apps/api/env/.env.unit.test b/apps/api/env/.env.unit.test index dd28ca35b9..4f5125221c 100644 --- a/apps/api/env/.env.unit.test +++ b/apps/api/env/.env.unit.test @@ -25,4 +25,6 @@ SQL_LOG_FORMAT=pretty DEPLOYMENT_ENV=test PROVIDER_PROXY_URL=http://localhost:3040 AMPLITUDE_API_KEY=AMPLITUDE_API_KEY -NOTIFICATIONS_API_BASE_URL=http://localhost:3081 \ No newline at end of file +NOTIFICATIONS_API_BASE_URL=http://localhost:3081 + +FEATURE_FLAGS_ENABLE_ALL=true diff --git a/apps/api/package.json b/apps/api/package.json index d62a1217be..eb152c9239 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -99,7 +99,8 @@ "sql-formatter": "^15.3.2", "stripe": "^16.8.0", "ts-results": "^3.3.0", - "tsyringe": "^4.8.0", + "tsyringe": "^4.10.0", + "unleash-client": "^6.6.0", "uuid": "^9.0.1", "zod": "3.*" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1f9bd726ff..48dad92f03 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ import "reflect-metadata"; import { LoggerService } from "@akashnetwork/logging"; import { HttpLoggerIntercepter } from "@akashnetwork/logging/hono"; +import type { ServerType } from "@hono/node-server"; import { serve } from "@hono/node-server"; import { otel } from "@hono/otel"; import { Hono } from "hono"; @@ -17,6 +18,7 @@ import packageJson from "../package.json"; import { apiKeysRouter } from "./auth/routes/api-keys/api-keys.router"; import { bidsRouter } from "./bid/routes/bids/bids.router"; import { certificateRouter } from "./certificate/routes/certificate.router"; +import { FeatureFlagsService } from "./core/services/feature-flags/feature-flags.service"; import { chainDb, syncUserSchema, userDb } from "./db/dbConnection"; import { deploymentSettingRouter } from "./deployment/routes/deployment-setting/deployment-setting.router"; import { deploymentsRouter } from "./deployment/routes/deployments/deployments.router"; @@ -173,16 +175,45 @@ export async function initApp() { await initDb(); startScheduler(); + await container.resolve(FeatureFlagsService).initialize(); + appLogger.info({ event: "SERVER_STARTING", url: `http://localhost:${PORT}` }); - serve({ + const server = serve({ fetch: appHono.fetch, - port: typeof PORT === "string" ? parseInt(PORT) : PORT + port: typeof PORT === "string" ? parseInt(PORT, 10) : PORT }); + const shutdown = () => shutdownServer(server); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); } catch (error) { appLogger.error({ event: "APP_INIT_ERROR", error }); } } +let isShuttingDown = false; +/** + * Shutdown the server and app services + */ +function shutdownServer(server: ServerType) { + if (isShuttingDown) return; + + isShuttingDown = true; + server.close(error => { + if (error) { + appLogger.error({ event: "SERVER_CLOSE_ERROR", error }); + } + + Promise.resolve(container.dispose()) + .catch(error => { + appLogger.error({ event: "CONTAINER_DISPOSE_ERROR", error }); + }) + .finally(() => { + isShuttingDown = false; + }); + }); +} + /** * Initialize database schema * Populate db diff --git a/apps/api/src/auth/services/auth.service.ts b/apps/api/src/auth/services/auth.service.ts index 6aaa5b8f04..bcfddeb4f6 100644 --- a/apps/api/src/auth/services/auth.service.ts +++ b/apps/api/src/auth/services/auth.service.ts @@ -15,7 +15,7 @@ export class AuthService { get currentUser(): UserOutput { // BUGALERT: https://github.com/akash-network/console/issues/1447 - return this.executionContextService.get("CURRENT_USER"); + return this.executionContextService.get("CURRENT_USER")!; } set ability(ability: Ability) { @@ -23,7 +23,7 @@ export class AuthService { } get ability(): Ability { - return this.executionContextService.get("ABILITY"); + return this.executionContextService.get("ABILITY")!; } get isAuthenticated(): boolean { diff --git a/apps/api/src/core/config/env.config.ts b/apps/api/src/core/config/env.config.ts index 364dfeb837..90a8fe15f6 100644 --- a/apps/api/src/core/config/env.config.ts +++ b/apps/api/src/core/config/env.config.ts @@ -1,19 +1,36 @@ import { z } from "zod"; -export const envSchema = z.object({ - LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).optional().default("info"), - STD_OUT_LOG_FORMAT: z.enum(["json", "pretty"]).optional().default("json"), - SQL_LOG_FORMAT: z.enum(["raw", "pretty"]).optional().default("raw"), - FLUENTD_TAG: z.string().optional().default("pino"), - FLUENTD_HOST: z.string().optional(), - FLUENTD_PORT: z.number({ coerce: true }).optional().default(24224), - NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development"), - POSTGRES_DB_URI: z.string(), - POSTGRES_MAX_CONNECTIONS: z.number({ coerce: true }).optional().default(20), - DRIZZLE_MIGRATIONS_FOLDER: z.string().optional().default("./drizzle"), - DEPLOYMENT_ENV: z.string().optional().default("production"), - AMPLITUDE_API_KEY: z.string(), - AMPLITUDE_SAMPLING: z.number({ coerce: true }).optional().default(1) -}); +export const envSchema = z + .object({ + LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).optional().default("info"), + STD_OUT_LOG_FORMAT: z.enum(["json", "pretty"]).optional().default("json"), + SQL_LOG_FORMAT: z.enum(["raw", "pretty"]).optional().default("raw"), + FLUENTD_TAG: z.string().optional().default("pino"), + FLUENTD_HOST: z.string().optional(), + FLUENTD_PORT: z.number({ coerce: true }).optional().default(24224), + NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development"), + POSTGRES_DB_URI: z.string(), + POSTGRES_MAX_CONNECTIONS: z.number({ coerce: true }).optional().default(20), + DRIZZLE_MIGRATIONS_FOLDER: z.string().optional().default("./drizzle"), + DEPLOYMENT_ENV: z.string().optional().default("production"), + NETWORK: z.enum(["mainnet", "testnet", "sandbox"]).default("mainnet"), + AMPLITUDE_API_KEY: z.string(), + AMPLITUDE_SAMPLING: z.number({ coerce: true }).optional().default(1), + UNLEASH_SERVER_API_URL: z.string().optional(), + UNLEASH_SERVER_API_TOKEN: z.string().optional(), + UNLEASH_APP_NAME: z.string().optional().default("console-api"), + FEATURE_FLAGS_ENABLE_ALL: z + .string() + .default("false") + .transform(value => value === "true") + }) + .superRefine((value, ctx) => { + if (!value.FEATURE_FLAGS_ENABLE_ALL && (!value.UNLEASH_SERVER_API_URL || !value.UNLEASH_SERVER_API_TOKEN)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "UNLEASH_SERVER_API_URL and UNLEASH_SERVER_API_TOKEN are required when FEATURE_FLAGS_ENABLE_ALL is false" + }); + } + }); export const envConfig = envSchema.parse(process.env); diff --git a/apps/api/src/core/services/config/config.service.ts b/apps/api/src/core/services/config/config.service.ts index be6202287e..0e6c8089b1 100644 --- a/apps/api/src/core/services/config/config.service.ts +++ b/apps/api/src/core/services/config/config.service.ts @@ -1,12 +1,12 @@ -import type { z, ZodObject, ZodRawShape } from "zod"; +import type { z, ZodEffects, ZodObject, ZodRawShape } from "zod"; -interface ConfigServiceOptions, C extends Record> { +interface ConfigServiceOptions | ZodEffects>, C extends Record> { envSchema?: E; config?: C; } // eslint-disable-next-line @typescript-eslint/ban-types -export class ConfigService, C extends Record = {}> { +export class ConfigService | ZodEffects>, C extends Record = {}> { private readonly config: C & z.infer; constructor(options: ConfigServiceOptions) { diff --git a/apps/api/src/core/services/core-config/core-config.service.ts b/apps/api/src/core/services/core-config/core-config.service.ts index 27c10d5d41..e536772186 100644 --- a/apps/api/src/core/services/core-config/core-config.service.ts +++ b/apps/api/src/core/services/core-config/core-config.service.ts @@ -1,7 +1,7 @@ import { singleton } from "tsyringe"; -import { envSchema } from "@src/core/config/env.config"; -import { ConfigService } from "@src/core/services/config/config.service"; +import { envSchema } from "../../config/env.config"; +import { ConfigService } from "../config/config.service"; @singleton() export class CoreConfigService extends ConfigService { diff --git a/apps/api/src/core/services/execution-context/execution-context.service.ts b/apps/api/src/core/services/execution-context/execution-context.service.ts index 48ab0d9a83..d3904fe648 100644 --- a/apps/api/src/core/services/execution-context/execution-context.service.ts +++ b/apps/api/src/core/services/execution-context/execution-context.service.ts @@ -1,9 +1,19 @@ +import type { MongoAbility } from "@casl/ability"; import { AsyncLocalStorage } from "node:async_hooks"; import { singleton } from "tsyringe"; +import type { UserOutput } from "@src/user/repositories"; +import type { AppContext } from "../../types/app-context"; + +interface ExecutionStorage { + CURRENT_USER: UserOutput; + ABILITY: MongoAbility; + HTTP_CONTEXT: AppContext; +} + @singleton() export class ExecutionContextService { - private readonly storage = new AsyncLocalStorage>(); + private readonly storage = new AsyncLocalStorage>(); private get context() { const store = this.storage.getStore(); @@ -15,12 +25,12 @@ export class ExecutionContextService { return store; } - set(key: string, value: any) { + set(key: K, value: ExecutionStorage[K] | undefined) { this.context.set(key, value); } - get(key: string) { - return this.context.get(key); + get(key: K): ExecutionStorage[K] | undefined { + return this.context.get(key) as ExecutionStorage[K] | undefined; } async runWithContext(cb: (...args: any[]) => Promise): Promise { diff --git a/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts b/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts new file mode 100644 index 0000000000..32f9e337ee --- /dev/null +++ b/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts @@ -0,0 +1,161 @@ +import { mock } from "jest-mock-extended"; +import type { Unleash, UnleashConfig } from "unleash-client"; + +import type { envConfig } from "@src/core/config/env.config"; +import type { ClientInfoContextVariables } from "@src/middlewares/clientInfoMiddleware"; +import type { CoreConfigService } from "../core-config/core-config.service"; +import type { ExecutionContextService } from "../execution-context/execution-context.service"; +import type { FeatureFlagValue } from "./feature-flags"; +import { FeatureFlags } from "./feature-flags"; +import { FeatureFlagsService } from "./feature-flags.service"; + +describe(FeatureFlagsService.name, () => { + it("creates Unleash instance with correct config", async () => { + const config = { + UNLEASH_SERVER_API_URL: "http://localhost:4242/api", + UNLEASH_SERVER_API_TOKEN: "default:development" + } as const; + const createClient = jest.fn(() => createUnleashMockClient()); + await setup({ + config, + createClient + }); + + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + url: config.UNLEASH_SERVER_API_URL, + customHeaders: { Authorization: config.UNLEASH_SERVER_API_TOKEN } + }) + ); + }); + + it("throws an error if service was not initialized but trying to check feature flag", async () => { + const service = await setup({ skipInitialization: true }); + expect(() => service.isEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION)).toThrow(/was not initialized/); + }); + + describe("isEnabled", () => { + it("passes user and environement specific context to Unleash", async () => { + const client = createUnleashMockClient({ + isEnabledFeatureFlag: jest.fn(() => false) + }); + const createClient = jest.fn(() => client); + const currentUser = { id: "123" }; + const httpClientInfo: ClientInfoContextVariables["clientInfo"] = { + ip: "127.0.0.1", + userAgent: "test", + fingerprint: "test" + }; + const config = { + DEPLOYMENT_ENV: "development", + NODE_ENV: "development", + NETWORK: "mainnet" + } as const; + const service = await setup({ + config, + createClient, + currentUser, + httpClientInfo + }); + + service.isEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION); + + expect(client.isEnabled).toHaveBeenCalledWith(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION, { + currentTime: expect.any(Date), + remoteAddress: httpClientInfo.ip, + userId: currentUser.id, + environment: config.DEPLOYMENT_ENV, + properties: { + userAgent: httpClientInfo.userAgent, + fingerprint: httpClientInfo.fingerprint, + nodeEnv: config.NODE_ENV, + chainNetwork: config.NETWORK + } + }); + }); + + it("returns false when feature flag is disabled", async () => { + const createClient = jest.fn(() => + createUnleashMockClient({ + isEnabledFeatureFlag: () => false + }) + ); + const service = await setup({ createClient }); + const result = service.isEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION); + + expect(result).toBe(false); + }); + + it("return true when feature flag is enabled", async () => { + const createClient = jest.fn(() => + createUnleashMockClient({ + isEnabledFeatureFlag: () => true + }) + ); + const service = await setup({ createClient }); + const result = service.isEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION); + + expect(result).toBe(true); + }); + }); + + async function setup(input: { + config?: Partial; + createClient?: (config: UnleashConfig) => Unleash; + currentUser?: { id: string }; + httpClientInfo?: ClientInfoContextVariables["clientInfo"]; + skipInitialization?: boolean; + }) { + const service = new FeatureFlagsService( + mock({ + get: key => + ( + ({ + FEATURE_FLAGS_ENABLE_ALL: false, + UNLEASH_SERVER_API_URL: "http://localhost:4242/api", + UNLEASH_SERVER_API_TOKEN: "default:development", + DEPLOYMENT_ENV: "development", + NODE_ENV: "development", + NETWORK: "mainnet", + ...input.config + }) as Record + )[key] + }), + mock({ + get: key => + ( + ({ + CURRENT_USER: input.currentUser ?? { id: "123" }, + HTTP_CONTEXT: { + get: (key: string) => + ( + ({ + clientInfo: input.httpClientInfo + }) as Record + )[key] + } + }) as any + )[key] + }), + input.createClient ?? (() => createUnleashMockClient()) + ); + + if (!input.skipInitialization) { + await service.initialize(); + } + + return service; + } + + function createUnleashMockClient(input?: { isEnabledFeatureFlag?: (featureFlag: FeatureFlagValue) => boolean }) { + return mock({ + once(event, callback) { + if (event === "synchronized") { + process.nextTick(callback); + } + return this as Unleash; + }, + isEnabled: input?.isEnabledFeatureFlag ? input.isEnabledFeatureFlag : () => false + }); + } +}); diff --git a/apps/api/src/core/services/feature-flags/feature-flags.service.ts b/apps/api/src/core/services/feature-flags/feature-flags.service.ts new file mode 100644 index 0000000000..66abae604d --- /dev/null +++ b/apps/api/src/core/services/feature-flags/feature-flags.service.ts @@ -0,0 +1,79 @@ +import assert from "assert"; +import { Disposable, inject, singleton } from "tsyringe"; +import { Unleash, UnleashConfig } from "unleash-client"; + +import { CoreConfigService } from "../core-config/core-config.service"; +import { ExecutionContextService } from "../execution-context/execution-context.service"; +import { FeatureFlagValue } from "./feature-flags"; + +@singleton() +export class FeatureFlagsService implements Disposable { + private readonly configService: CoreConfigService; + private readonly executionContext: ExecutionContextService; + private client?: Unleash; + private readonly createClient: typeof createUnleashClient; + + constructor( + configService: CoreConfigService, + executionContext: ExecutionContextService, + @inject("createUnleashClient", { isOptional: true }) createClient = createUnleashClient + ) { + this.configService = configService; + this.executionContext = executionContext; + this.createClient = createClient; + } + + isEnabled(featureFlag: FeatureFlagValue): boolean { + if (this.configService.get("FEATURE_FLAGS_ENABLE_ALL")) return true; + + assert(this.client, "Feature flags service was not initialized. Call initialize() method first."); + + const clientInfo = this.executionContext.get("HTTP_CONTEXT")?.get("clientInfo"); + const currentUser = this.executionContext.get("CURRENT_USER"); + + return this.client.isEnabled(featureFlag, { + currentTime: new Date(), + remoteAddress: clientInfo?.ip, + userId: currentUser?.id, + environment: this.configService.get("DEPLOYMENT_ENV"), + properties: { + userAgent: clientInfo?.userAgent, + fingerprint: clientInfo?.fingerprint, + nodeEnv: this.configService.get("NODE_ENV"), + chainNetwork: this.configService.get("NETWORK") + } + }); + } + + onChanged(callback: () => void) { + this.client?.on("changed", callback); + } + + async initialize(): Promise { + const url = this.configService.get("UNLEASH_SERVER_API_URL"); + const token = this.configService.get("UNLEASH_SERVER_API_TOKEN"); + + assert(url && token, "UNLEASH_SERVER_API_URL and UNLEASH_SERVER_API_TOKEN are required"); + const client = this.createClient({ + url, + appName: this.configService.get("UNLEASH_APP_NAME"), + customHeaders: { Authorization: token } + }); + + await new Promise((resolve, reject) => { + client.once("synchronized", resolve); + client.once("error", reject); + }); + + this.client = client; + } + + dispose(): void { + this.client?.destroyWithFlush(); + this.client?.removeAllListeners(); + } +} + +export function createUnleashClient(config: UnleashConfig): Unleash { + return new Unleash(config); +} diff --git a/apps/api/src/core/services/feature-flags/feature-flags.ts b/apps/api/src/core/services/feature-flags/feature-flags.ts new file mode 100644 index 0000000000..8aaa4fbf08 --- /dev/null +++ b/apps/api/src/core/services/feature-flags/feature-flags.ts @@ -0,0 +1,5 @@ +export const FeatureFlags = { + NOTIFICATIONS_ALERT_MUTATION: "notifications_general_alerts_create_update" +} as const; + +export type FeatureFlagValue = (typeof FeatureFlags)[keyof typeof FeatureFlags]; diff --git a/apps/api/src/notifications/routes/proxy/proxy.route.spec.ts b/apps/api/src/notifications/routes/proxy/proxy.route.spec.ts index d9e20c0926..8c9ef00a60 100644 --- a/apps/api/src/notifications/routes/proxy/proxy.route.spec.ts +++ b/apps/api/src/notifications/routes/proxy/proxy.route.spec.ts @@ -2,6 +2,7 @@ import { faker } from "@faker-js/faker"; import type { AuthService } from "@src/auth/services/auth.service"; import type { UserWalletRepository } from "@src/billing/repositories"; +import type { AppContext } from "@src/core/types/app-context"; import type { NotificationsConfig } from "@src/notifications/config"; import { createProxy } from "@src/notifications/routes/proxy/proxy.route"; @@ -98,8 +99,9 @@ describe("createProxy", () => { headers: new Headers({ "x-custom": faker.internet.domainWord() }) }, text: async () => JSON.stringify(body) - } - }; + }, + get: jest.fn().mockReturnValue(undefined) + } as unknown as AppContext; return { handler, diff --git a/apps/api/src/notifications/routes/proxy/proxy.route.ts b/apps/api/src/notifications/routes/proxy/proxy.route.ts index d8ad1e55cc..93ff02f73a 100644 --- a/apps/api/src/notifications/routes/proxy/proxy.route.ts +++ b/apps/api/src/notifications/routes/proxy/proxy.route.ts @@ -4,13 +4,17 @@ import { container } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; import { UserWalletRepository } from "@src/billing/repositories"; +import type { FeatureFlagValue } from "@src/core/services/feature-flags/feature-flags"; +import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; +import { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; +import type { AppContext } from "@src/core/types/app-context"; import type { NotificationsConfig } from "@src/notifications/config"; import { config } from "@src/notifications/config"; const notificationsApiProxy = new Hono(); export const createProxy = - (authService: AuthService, userWalletRepository: UserWalletRepository, config: NotificationsConfig, fetchFn: typeof fetch) => async (c: any) => { + (authService: AuthService, userWalletRepository: UserWalletRepository, config: NotificationsConfig, fetchFn: typeof fetch) => async (c: AppContext) => { const { req } = c; const headers = Object.fromEntries([...req.raw.headers.entries()].map(([k, v]) => [k.toLowerCase(), v])); @@ -32,7 +36,9 @@ export const createProxy = assert(userWallet, 403, "User does not have a managed wallet"); - headers["x-owner-address"] = userWallet.address; + if (userWallet.address) { + headers["x-owner-address"] = userWallet.address; + } if (isBodyAllowed && !headers["content-type"]) { headers["content-type"] = "application/json"; @@ -48,11 +54,24 @@ export const createProxy = }; const proxyRoute = createProxy(container.resolve(AuthService), container.resolve(UserWalletRepository), config, fetch); +const proxyRouteIfEnabled = (featureFlag: FeatureFlagValue) => { + return async (c: AppContext) => { + const isEnabled = await container.resolve(FeatureFlagsService).isEnabled(featureFlag); + if (!isEnabled) return c.json({ error: "MethodNotAllowed" }, 405); + + return proxyRoute(c); + }; +}; notificationsApiProxy.all("/v1/notification-channels/*", proxyRoute); notificationsApiProxy.all("/v1/notification-channels", proxyRoute); -notificationsApiProxy.all("/v1/alerts/*", proxyRoute); -notificationsApiProxy.all("/v1/alerts", proxyRoute); + +notificationsApiProxy.get("/v1/alerts", proxyRoute); +notificationsApiProxy.post("/v1/alerts", proxyRouteIfEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION)); +notificationsApiProxy.get("/v1/alerts/*", proxyRoute); +notificationsApiProxy.delete("/v1/alerts/*", proxyRoute); +notificationsApiProxy.patch("/v1/alerts/*", proxyRouteIfEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION)); + notificationsApiProxy.all("/v1/deployment-alerts/*", proxyRoute); notificationsApiProxy.all("/v1/deployment-alerts", proxyRoute); diff --git a/apps/api/src/user/controllers/user/user.controller.ts b/apps/api/src/user/controllers/user/user.controller.ts index 0355a72502..d8fd64bf51 100644 --- a/apps/api/src/user/controllers/user/user.controller.ts +++ b/apps/api/src/user/controllers/user/user.controller.ts @@ -24,7 +24,7 @@ export class UserController { ) {} get httpContext(): Context { - return this.executionContextService.get("HTTP_CONTEXT"); + return this.executionContextService.get("HTTP_CONTEXT")!; } async create(): Promise { diff --git a/package-lock.json b/package-lock.json index 8b37f87489..4c63fa80b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,8 @@ "sql-formatter": "^15.3.2", "stripe": "^16.8.0", "ts-results": "^3.3.0", - "tsyringe": "^4.8.0", + "tsyringe": "^4.10.0", + "unleash-client": "^6.6.0", "uuid": "^9.0.1", "zod": "3.*" }, @@ -49800,9 +49801,10 @@ } }, "node_modules/tsyringe": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", - "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", "dependencies": { "tslib": "^1.9.3" }, diff --git a/packages/docker/docker-compose.dev.yml b/packages/docker/docker-compose.dev.yml index 8cee45cea5..8b68a7292c 100644 --- a/packages/docker/docker-compose.dev.yml +++ b/packages/docker/docker-compose.dev.yml @@ -61,3 +61,43 @@ services: - /app/node_modules - /app/apps/provider-console/node_modules - /app/apps/provider-console/.next + + mock-oauth2-server: + image: ghcr.io/navikt/mock-oauth2-server:2.1.10 + ports: + - "8080:8080" + volumes: + - ./oauth/login.html:/app/config/login.html:ro + environment: + JSON_CONFIG: | + { + "interactiveLogin": true, + "loginPagePath": "/app/config/login.html", + "tokenCallbacks": [ + { + "issuerId": "default", + "tokenExpiry": 3600, + "requestMappings": [ + { + "requestParam": "code", + "match": "debug", + "claims": { + "sub": "user123", + "aud": ["my-audience"], + "email": "dev@example.com", + "nickname": "dev" + } + }, + { + "requestParam": "client_id", + "match": "m2m-client", + "claims": { + "sub": "m2m-client", + "aud": ["my-audience"], + "scope": "read write" + } + } + ] + } + ] + } From a4bc5eadeb2cbf79aa0724d582c7f1bbaf90063f Mon Sep 17 00:00:00 2001 From: Sergii Date: Fri, 13 Jun 2025 10:55:59 +0300 Subject: [PATCH 2/2] chore: improve coverage --- apps/api/src/app.ts | 27 +------ .../feature-flags.service.spec.ts | 23 ++++++ .../shutdown-server/shutdown-server.spec.ts | 73 +++++++++++++++++++ .../shutdown-server/shutdown-server.ts | 35 +++++++++ .../notifications/routes/proxy/proxy.route.ts | 3 +- 5 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/core/services/shutdown-server/shutdown-server.spec.ts create mode 100644 apps/api/src/core/services/shutdown-server/shutdown-server.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 48dad92f03..dd75ac8a86 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,7 +2,6 @@ import "reflect-metadata"; import { LoggerService } from "@akashnetwork/logging"; import { HttpLoggerIntercepter } from "@akashnetwork/logging/hono"; -import type { ServerType } from "@hono/node-server"; import { serve } from "@hono/node-server"; import { otel } from "@hono/otel"; import { Hono } from "hono"; @@ -19,6 +18,7 @@ import { apiKeysRouter } from "./auth/routes/api-keys/api-keys.router"; import { bidsRouter } from "./bid/routes/bids/bids.router"; import { certificateRouter } from "./certificate/routes/certificate.router"; import { FeatureFlagsService } from "./core/services/feature-flags/feature-flags.service"; +import { shutdownServer } from "./core/services/shutdown-server/shutdown-server"; import { chainDb, syncUserSchema, userDb } from "./db/dbConnection"; import { deploymentSettingRouter } from "./deployment/routes/deployment-setting/deployment-setting.router"; import { deploymentsRouter } from "./deployment/routes/deployments/deployments.router"; @@ -182,7 +182,7 @@ export async function initApp() { fetch: appHono.fetch, port: typeof PORT === "string" ? parseInt(PORT, 10) : PORT }); - const shutdown = () => shutdownServer(server); + const shutdown = () => shutdownServer(server, appLogger, container.dispose.bind(container)); process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); @@ -191,29 +191,6 @@ export async function initApp() { } } -let isShuttingDown = false; -/** - * Shutdown the server and app services - */ -function shutdownServer(server: ServerType) { - if (isShuttingDown) return; - - isShuttingDown = true; - server.close(error => { - if (error) { - appLogger.error({ event: "SERVER_CLOSE_ERROR", error }); - } - - Promise.resolve(container.dispose()) - .catch(error => { - appLogger.error({ event: "CONTAINER_DISPOSE_ERROR", error }); - }) - .finally(() => { - isShuttingDown = false; - }); - }); -} - /** * Initialize database schema * Populate db diff --git a/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts b/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts index 32f9e337ee..3d94676c3f 100644 --- a/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts +++ b/apps/api/src/core/services/feature-flags/feature-flags.service.spec.ts @@ -34,6 +34,29 @@ describe(FeatureFlagsService.name, () => { expect(() => service.isEnabled(FeatureFlags.NOTIFICATIONS_ALERT_MUTATION)).toThrow(/was not initialized/); }); + it("calls onChanged callback when feature flag is changed", async () => { + const client = createUnleashMockClient({ + isEnabledFeatureFlag: jest.fn(() => false) + }); + const service = await setup({ createClient: () => client }); + + const callback = () => {}; + service.onChanged(callback); + + expect(client.on).toHaveBeenCalledTimes(1); + expect(client.on).toHaveBeenCalledWith("changed", callback); + }); + + it("clears event listeners and destroys client when dispose is called", async () => { + const client = createUnleashMockClient(); + const service = await setup({ createClient: () => client }); + + service.dispose(); + + expect(client.destroyWithFlush).toHaveBeenCalledTimes(1); + expect(client.removeAllListeners).toHaveBeenCalledTimes(1); + }); + describe("isEnabled", () => { it("passes user and environement specific context to Unleash", async () => { const client = createUnleashMockClient({ diff --git a/apps/api/src/core/services/shutdown-server/shutdown-server.spec.ts b/apps/api/src/core/services/shutdown-server/shutdown-server.spec.ts new file mode 100644 index 0000000000..f972e13d80 --- /dev/null +++ b/apps/api/src/core/services/shutdown-server/shutdown-server.spec.ts @@ -0,0 +1,73 @@ +import type { Logger } from "@akashnetwork/logging"; +import type { ServerType } from "@hono/node-server"; +import { mock } from "jest-mock-extended"; + +import { shutdownServer } from "./shutdown-server"; + +describe(shutdownServer.name, () => { + it("closes the server and ignores further calls until it is done", async () => { + const server = mock({ + close: jest.fn().mockImplementation(cb => cb()) + }); + const appLogger = mock(); + const onShutdown = jest.fn(); + + await Promise.all(Array.from({ length: 5 }, () => shutdownServer(server, appLogger, onShutdown))); + + expect(server.close).toHaveBeenCalledTimes(1); + expect(onShutdown).toHaveBeenCalledTimes(1); + expect(appLogger.error).not.toHaveBeenCalled(); + }); + + it("logs error if server close fails", async () => { + const error = new Error("Failed to close server"); + const server = mock({ + close: jest.fn().mockImplementation(cb => cb(error)) + }); + const appLogger = mock(); + const onShutdown = jest.fn(); + + await shutdownServer(server, appLogger, onShutdown); + + expect(appLogger.error).toHaveBeenCalledWith({ + event: "SERVER_CLOSE_ERROR", + error + }); + expect(onShutdown).toHaveBeenCalled(); + }); + + it("logs error if server close throws", async () => { + const error = new Error("Failed to close server"); + const server = mock({ + close: jest.fn().mockImplementation(() => { + throw error; + }) as jest.Mock + }); + const appLogger = mock(); + const onShutdown = jest.fn(); + + await shutdownServer(server, appLogger, onShutdown); + + expect(appLogger.error).toHaveBeenCalledWith({ + event: "SERVER_CLOSE_ERROR", + error + }); + expect(onShutdown).toHaveBeenCalled(); + }); + + it("logs error if onShutdown callback fails", async () => { + const error = new Error("Failed to dispose container"); + const server = mock({ + close: jest.fn().mockImplementation(cb => cb()) + }); + const appLogger = mock(); + const onShutdown = jest.fn().mockRejectedValue(error); + + await shutdownServer(server, appLogger, onShutdown); + + expect(appLogger.error).toHaveBeenCalledWith({ + event: "CONTAINER_DISPOSE_ERROR", + error + }); + }); +}); diff --git a/apps/api/src/core/services/shutdown-server/shutdown-server.ts b/apps/api/src/core/services/shutdown-server/shutdown-server.ts new file mode 100644 index 0000000000..91263ec168 --- /dev/null +++ b/apps/api/src/core/services/shutdown-server/shutdown-server.ts @@ -0,0 +1,35 @@ +import type { Logger } from "@akashnetwork/logging"; +import type { ServerType } from "@hono/node-server"; + +let isShuttingDown = false; +/** + * Shutdown the server and app services + */ +export async function shutdownServer(server: ServerType, appLogger: Logger, onShutdown: () => void | Promise): Promise { + if (isShuttingDown) return; + + isShuttingDown = true; + + return new Promise(resolve => { + const shutdown = (error: unknown) => { + if (error) { + appLogger.error({ event: "SERVER_CLOSE_ERROR", error }); + } + + Promise.resolve(onShutdown()) + .catch(error => { + appLogger.error({ event: "CONTAINER_DISPOSE_ERROR", error }); + }) + .finally(() => { + isShuttingDown = false; + resolve(); + }); + }; + + try { + server.close(shutdown); + } catch (error) { + shutdown(error); + } + }); +} diff --git a/apps/api/src/notifications/routes/proxy/proxy.route.ts b/apps/api/src/notifications/routes/proxy/proxy.route.ts index 93ff02f73a..62ad5ed673 100644 --- a/apps/api/src/notifications/routes/proxy/proxy.route.ts +++ b/apps/api/src/notifications/routes/proxy/proxy.route.ts @@ -56,8 +56,7 @@ export const createProxy = const proxyRoute = createProxy(container.resolve(AuthService), container.resolve(UserWalletRepository), config, fetch); const proxyRouteIfEnabled = (featureFlag: FeatureFlagValue) => { return async (c: AppContext) => { - const isEnabled = await container.resolve(FeatureFlagsService).isEnabled(featureFlag); - if (!isEnabled) return c.json({ error: "MethodNotAllowed" }, 405); + if (!container.resolve(FeatureFlagsService).isEnabled(featureFlag)) return c.json({ error: "MethodNotAllowed" }, 405); return proxyRoute(c); };