diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 9ff7e3c66..135f1d065 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,3 +1,5 @@ +import "@akashnetwork/env-loader"; + import { defineConfig } from "drizzle-kit"; import { config } from "./src/core/config"; diff --git a/apps/api/drizzle/0006_skinny_stingray.sql b/apps/api/drizzle/0006_skinny_stingray.sql new file mode 100644 index 000000000..bba722a7b --- /dev/null +++ b/apps/api/drizzle/0006_skinny_stingray.sql @@ -0,0 +1,8 @@ +ALTER TABLE "user_wallets" DROP CONSTRAINT "user_wallets_user_id_userSetting_id_fk"; +--> statement-breakpoint +ALTER TABLE "userSetting" ADD COLUMN "last_active_at" timestamp DEFAULT now();--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_wallets" ADD CONSTRAINT "user_wallets_user_id_userSetting_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."userSetting"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/api/drizzle/meta/0006_snapshot.json b/apps/api/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..f1925992b --- /dev/null +++ b/apps/api/drizzle/meta/0006_snapshot.json @@ -0,0 +1,286 @@ +{ + "id": "d83b4940-34c1-400c-98d9-5a1eb935fe5e", + "prevId": "d6102ad7-0e0c-4ef8-8712-6a626f5ad2a1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + } + }, + "public.checkout_sessions": { + "name": "checkout_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkout_sessions_user_id_userSetting_id_fk": { + "name": "checkout_sessions_user_id_userSetting_id_fk", + "tableFrom": "checkout_sessions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "checkout_sessions_session_id_unique": { + "name": "checkout_sessions_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "session_id" + ] + } + } + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index f572b9904..da0149e47 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1724745930642, "tag": "0005_colorful_dreaming_celestial", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1731579448104, + "tag": "0006_skinny_stingray", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/auth/services/auth.interceptor.ts b/apps/api/src/auth/services/auth.interceptor.ts index 84e22dd3d..10a014880 100644 --- a/apps/api/src/auth/services/auth.interceptor.ts +++ b/apps/api/src/auth/services/auth.interceptor.ts @@ -6,7 +6,7 @@ import { AuthService } from "@src/auth/services/auth.service"; import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service"; import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type"; import { kvStore } from "@src/middlewares/userMiddleware"; -import { UserRepository } from "@src/user/repositories"; +import { UserOutput, UserRepository } from "@src/user/repositories"; import { env } from "@src/utils/env"; import { getJwks, useKVStore, verify } from "@src/verify-rsa-jwt-cloudflare-worker-main"; @@ -27,10 +27,7 @@ export class AuthInterceptor implements HonoInterceptor { if (anonymousUserId) { const currentUser = await this.userRepository.findAnonymousById(anonymousUserId); - - this.authService.currentUser = currentUser; - this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_ANONYMOUS_USER", currentUser) : this.abilityService.EMPTY_ABILITY; - + await this.auth(currentUser); return await next(); } @@ -38,10 +35,7 @@ export class AuthInterceptor implements HonoInterceptor { if (userId) { const currentUser = await this.userRepository.findByUserId(userId); - - this.authService.currentUser = currentUser; - this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_USER", currentUser) : this.abilityService.EMPTY_ABILITY; - + this.auth(currentUser); return await next(); } @@ -51,6 +45,16 @@ export class AuthInterceptor implements HonoInterceptor { }; } + private async auth(user?: UserOutput) { + this.authService.currentUser = user; + if (user) { + this.authService.ability = this.abilityService.getAbilityFor(user.userId ? "REGULAR_ANONYMOUS_USER" : "REGULAR_USER", user); + await this.userRepository.markAsActive(user.id); + } else { + this.authService.ability = this.abilityService.EMPTY_ABILITY; + } + } + private async getValidUserId(bearer: string, c: Context) { const token = bearer.replace(/^Bearer\s+/i, ""); const jwks = await getJwks(env.AUTH0_JWKS_URI || c.env?.JWKS_URI, useKVStore(kvStore || c.env?.VERIFY_RSA_JWT), c.env?.VERIFY_RSA_JWT_JWKS_CACHE_KEY); diff --git a/apps/api/src/billing/config/env.config.ts b/apps/api/src/billing/config/env.config.ts index cf0d1366d..4030f2b2b 100644 --- a/apps/api/src/billing/config/env.config.ts +++ b/apps/api/src/billing/config/env.config.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -const envSchema = z.object({ +export const envSchema = z.object({ MASTER_WALLET_MNEMONIC: z.string(), UAKT_TOP_UP_MASTER_WALLET_MNEMONIC: z.string(), USDC_TOP_UP_MASTER_WALLET_MNEMONIC: z.string(), diff --git a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts index 02084744d..476172f18 100644 --- a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts +++ b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts @@ -5,7 +5,7 @@ import { Users } from "@src/user/model-schemas"; export const UserWallets = pgTable("user_wallets", { id: serial("id").primaryKey(), userId: uuid("user_id") - .references(() => Users.id) + .references(() => Users.id, { onDelete: "cascade" }) .unique(), address: varchar("address").unique(), stripeCustomerId: varchar("stripe_customer_id"), diff --git a/apps/api/src/billing/providers/wallet.provider.ts b/apps/api/src/billing/providers/wallet.provider.ts index aec733c49..a62811805 100644 --- a/apps/api/src/billing/providers/wallet.provider.ts +++ b/apps/api/src/billing/providers/wallet.provider.ts @@ -14,3 +14,5 @@ export const USDC_TOP_UP_MASTER_WALLET = "USDC_TOP_UP_MASTER_WALLET"; container.register(USDC_TOP_UP_MASTER_WALLET, { useFactory: () => new MasterWalletService(config.USDC_TOP_UP_MASTER_WALLET_MNEMONIC) }); export const InjectWallet = (walletType: MasterWalletType) => inject(`${walletType}_MASTER_WALLET`); + +export const resolveWallet = (walletType: MasterWalletType) => container.resolve(`${walletType}_MASTER_WALLET`); diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index 24af4efcc..b3363d480 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -1,4 +1,4 @@ -import { eq, lte } from "drizzle-orm"; +import { eq, inArray, lte } from "drizzle-orm"; import first from "lodash/first"; import omit from "lodash/omit"; import pick from "lodash/pick"; @@ -64,10 +64,15 @@ export class UserWalletRepository extends BaseRepository) { - const feeAllowances = await this.allowanceHttpService.getFeeAllowancesForGrantee(options.grantee); - const feeAllowance = feeAllowances.find(allowance => allowance.granter === options.granter); const results: Promise[] = []; - if (feeAllowance) { + if (await this.allowanceHttpService.hasFeeAllowance(options.granter, options.grantee)) { results.push(this.masterSigningClientService.executeTx([this.rpcMessageService.getRevokeAllowanceMsg(options)])); } @@ -111,4 +109,26 @@ export class ManagedUserWalletService { const deploymentAllowanceMsg = this.rpcMessageService.getDepositDeploymentGrantMsg(options); return await this.masterSigningClientService.executeTx([deploymentAllowanceMsg]); } + + async revokeAll(grantee: string, reason?: string) { + const masterWalletAddress = await this.masterWalletService.getFirstAddress(); + const params = { granter: masterWalletAddress, grantee }; + const messages: EncodeObject[] = []; + const revokeTypes: string[] = []; + + if (await this.allowanceHttpService.hasFeeAllowance(params.granter, params.grantee)) { + revokeTypes.push("REVOKE_ALLOWANCE"); + messages.push(this.rpcMessageService.getRevokeAllowanceMsg(params)); + } + + if (await this.allowanceHttpService.hasDeploymentGrant(params.granter, params.grantee)) { + revokeTypes.push("REVOKE_DEPOSIT_DEPLOYMENT_GRANT"); + messages.push(this.rpcMessageService.getRevokeDepositDeploymentGrantMsg(params)); + } + + if (messages.length) { + await this.masterSigningClientService.executeTx(messages); + this.logger.info({ event: "SPENDING_REVOKED", address: params.grantee, revokeTypes, reason }); + } + } } diff --git a/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts b/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts index 5af13c19d..d16baa45a 100644 --- a/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts +++ b/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts @@ -68,7 +68,7 @@ export class RpcMessageService { grantee, grant: { authorization: { - typeUrl: "/akash.deployment.v1beta3.DepositDeploymentAuthorization", + typeUrl: `/${DepositDeploymentAuthorization.$type}`, value: DepositDeploymentAuthorization.encode( DepositDeploymentAuthorization.fromPartial({ spendLimit: { @@ -100,6 +100,17 @@ export class RpcMessageService { }; } + getRevokeDepositDeploymentGrantMsg({ granter, grantee }: { granter: string; grantee: string }) { + return { + typeUrl: MsgRevoke.typeUrl, + value: MsgRevoke.fromPartial({ + granter: granter, + grantee: grantee, + msgTypeUrl: "/akash.deployment.v1beta3.MsgDepositDeployment" + }) + }; + } + getCloseDeploymentMsg(address: string, dseq: number) { return { typeUrl: `/${MsgCloseDeployment.$type}`, diff --git a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts index dcf8f889c..d8db89ad9 100644 --- a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts +++ b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts @@ -39,7 +39,7 @@ export class TxSignerService { ) {} async signAndBroadcast(userId: UserWalletOutput["userId"], messages: StringifiedEncodeObject[]) { - const userWallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findByUserId(userId); + const userWallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findOneByUserId(userId); assert(userWallet, 404, "UserWallet Not Found"); const decodedMessages = this.decodeMessages(messages); diff --git a/apps/api/src/console.ts b/apps/api/src/console.ts index 41b8900bf..409f9b4ee 100644 --- a/apps/api/src/console.ts +++ b/apps/api/src/console.ts @@ -11,6 +11,8 @@ import { container } from "tsyringe"; import { WalletController } from "@src/billing/controllers/wallet/wallet.controller"; import { chainDb } from "@src/db/dbConnection"; import { TopUpDeploymentsController } from "@src/deployment/controllers/deployment/deployment.controller"; +import { UserController } from "@src/user/controllers/user/user.controller"; +import { UserConfigService } from "@src/user/services/user-config/user-config.service"; const program = new Command(); @@ -45,6 +47,16 @@ program }); }); +const userConfig = container.resolve(UserConfigService); +program + .command("cleanup-stale-anonymous-users") + .description(`Remove users that have been inactive for ${userConfig.get("STALE_ANONYMOUS_USERS_LIVE_IN_DAYS")} days`) + .action(async (options, command) => { + await executeCliHandler(command.name(), async () => { + await container.resolve(UserController).cleanUpStaleAnonymousUsers(); + }); + }); + const logger = LoggerService.forContext("CLI"); async function executeCliHandler(name: string, handler: () => Promise) { diff --git a/apps/api/src/core/repositories/base.repository.ts b/apps/api/src/core/repositories/base.repository.ts index 0fa9ddc9e..8ef0e18b8 100644 --- a/apps/api/src/core/repositories/base.repository.ts +++ b/apps/api/src/core/repositories/base.repository.ts @@ -1,5 +1,5 @@ import { AnyAbility } from "@casl/ability"; -import { and, DBQueryConfig, eq } from "drizzle-orm"; +import { and, DBQueryConfig, eq, inArray, sql } from "drizzle-orm"; import { PgTableWithColumns } from "drizzle-orm/pg-core/table"; import { SQL } from "drizzle-orm/sql/sql"; import first from "lodash/first"; @@ -98,15 +98,19 @@ export abstract class BaseRepository< return this.toOutputList(await this.queryCursor.findMany(params)); } - async paginate(options: { select?: Array; limit?: number; query?: Partial }, cb: (page: Output[]) => Promise) { + async paginate({ query, ...options }: { select?: Array; limit?: number; query?: Partial }, cb: (page: Output[]) => Promise) { + return this.paginateRaw({ ...options, where: this.queryToWhere(query) }, cb); + } + + protected async paginateRaw(params: Omit, "offset">, cb: (page: Output[]) => Promise) { let offset = 0; let hasNextPage = true; - const limit = options?.limit || 100; + params.limit = params.limit || 100; while (hasNextPage) { - const items = await this.find(options.query, { select: options.select, offset, limit }); + const items = this.toOutputList(await this.queryCursor.findMany({ ...params, offset })); offset += items.length; - hasNextPage = items.length === limit; + hasNextPage = items.length === params.limit; if (items.length) { await cb(items); @@ -123,7 +127,13 @@ export abstract class BaseRepository< async updateBy(query: Partial, payload: Partial, options?: MutationOptions): Promise; async updateBy(query: Partial, payload: Partial): Promise; async updateBy(query: Partial, payload: Partial, options?: MutationOptions): Promise { - const cursor = this.cursor.update(this.table).set(this.toInput(payload)).where(this.queryToWhere(query)); + const cursor = this.cursor + .update(this.table) + .set({ + ...this.toInput(payload), + updated_at: sql`now()` + }) + .where(this.queryToWhere(query)); if (options?.returning) { const items = await cursor.returning(); @@ -135,6 +145,11 @@ export abstract class BaseRepository< return undefined; } + async deleteById(id: Output["id"] | Output["id"][]): Promise { + const where = Array.isArray(id) ? inArray(this.table.id, id) : eq(this.table.id, id); + await this.cursor.delete(this.table).where(this.whereAccessibleBy(where)); + } + async deleteBy(query: Partial, options?: MutationOptions): Promise; async deleteBy(query: Partial): Promise; async deleteBy(query: Partial, options?: MutationOptions): Promise { diff --git a/apps/api/src/core/services/config/config.service.ts b/apps/api/src/core/services/config/config.service.ts new file mode 100644 index 000000000..6cdeae820 --- /dev/null +++ b/apps/api/src/core/services/config/config.service.ts @@ -0,0 +1,21 @@ +import { z, ZodObject, ZodRawShape } from "zod"; + +interface ConfigServiceOptions, C extends Record> { + envSchema?: E; + config?: C; +} + +export class ConfigService, C extends Record> { + private readonly config: C & z.infer; + + constructor(options: ConfigServiceOptions) { + this.config = { + ...options.config, + ...options.envSchema?.parse(process.env) + }; + } + + get(key: K): (typeof this.config)[K] { + return this.config[key]; + } +} diff --git a/apps/api/src/user/config/env.config.ts b/apps/api/src/user/config/env.config.ts new file mode 100644 index 000000000..258634d18 --- /dev/null +++ b/apps/api/src/user/config/env.config.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const envSchema = z.object({ + STALE_ANONYMOUS_USERS_LIVE_IN_DAYS: z.number().optional().default(90) +}); diff --git a/apps/api/src/user/controllers/user/user.controller.ts b/apps/api/src/user/controllers/user/user.controller.ts index 3d74e06f5..5d02fe406 100644 --- a/apps/api/src/user/controllers/user/user.controller.ts +++ b/apps/api/src/user/controllers/user/user.controller.ts @@ -6,13 +6,15 @@ import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.servi import { UserRepository } from "@src/user/repositories"; import { GetUserParams } from "@src/user/routes/get-anonymous-user/get-anonymous-user.router"; import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema"; +import { StaleAnonymousUsersCleanerService } from "@src/user/services/stale-anonymous-users-cleaner/stale-anonymous-users-cleaner.service"; @singleton() export class UserController { constructor( private readonly userRepository: UserRepository, private readonly authService: AuthService, - private readonly anonymousUserAuthService: AuthTokenService + private readonly anonymousUserAuthService: AuthTokenService, + private readonly staleAnonymousUsersCleanerService: StaleAnonymousUsersCleanerService ) {} async create(): Promise { @@ -31,4 +33,8 @@ export class UserController { return { data: user }; } + + async cleanUpStaleAnonymousUsers() { + await this.staleAnonymousUsersCleanerService.cleanUpStaleAnonymousUsers(); + } } diff --git a/apps/api/src/user/model-schemas/user/user.schema.ts b/apps/api/src/user/model-schemas/user/user.schema.ts index 70bf89437..7b16c1881 100644 --- a/apps/api/src/user/model-schemas/user/user.schema.ts +++ b/apps/api/src/user/model-schemas/user/user.schema.ts @@ -16,5 +16,6 @@ export const Users = pgTable("userSetting", { youtubeUsername: varchar("youtubeUsername", { length: 255 }), twitterUsername: varchar("twitterUsername", { length: 255 }), githubUsername: varchar("githubUsername", { length: 255 }), + lastActiveAt: timestamp("last_active_at").defaultNow(), createdAt: timestamp("created_at").defaultNow() }); diff --git a/apps/api/src/user/repositories/user/user.repository.ts b/apps/api/src/user/repositories/user/user.repository.ts index 8f75d64af..d2bcbb632 100644 --- a/apps/api/src/user/repositories/user/user.repository.ts +++ b/apps/api/src/user/repositories/user/user.repository.ts @@ -1,4 +1,5 @@ -import { and, eq, isNull } from "drizzle-orm"; +import subDays from "date-fns/subDays"; +import { and, eq, isNull, lte, sql } from "drizzle-orm"; import first from "lodash/first"; import { singleton } from "tsyringe"; @@ -34,4 +35,15 @@ export class UserRepository extends BaseRepository Promise) { + await this.paginateRaw({ where: and(isNull(this.table.userId), lte(this.table.lastActiveAt, subDays(new Date(), inactivityInDays))), ...params }, cb); + } } diff --git a/apps/api/src/user/services/stale-anonymous-users-cleaner/stale-anonymous-users-cleaner.service.ts b/apps/api/src/user/services/stale-anonymous-users-cleaner/stale-anonymous-users-cleaner.service.ts new file mode 100644 index 000000000..a714bef4f --- /dev/null +++ b/apps/api/src/user/services/stale-anonymous-users-cleaner/stale-anonymous-users-cleaner.service.ts @@ -0,0 +1,54 @@ +import { LoggerService } from "@akashnetwork/logging"; +import { PromisePool } from "@supercharge/promise-pool"; +import difference from "lodash/difference"; +import { singleton } from "tsyringe"; + +import { UserWalletRepository } from "@src/billing/repositories"; +import { ManagedUserWalletService } from "@src/billing/services"; +import { InjectSentry, Sentry } from "@src/core/providers/sentry.provider"; +import { SentryEventService } from "@src/core/services/sentry-event/sentry-event.service"; +import { UserRepository } from "@src/user/repositories"; +import { UserConfigService } from "@src/user/services/user-config/user-config.service"; + +@singleton() +export class StaleAnonymousUsersCleanerService { + private readonly CONCURRENCY = 10; + + private readonly logger = LoggerService.forContext(StaleAnonymousUsersCleanerService.name); + + constructor( + private readonly userRepository: UserRepository, + private readonly userWalletRepository: UserWalletRepository, + private readonly managedUserWalletService: ManagedUserWalletService, + private readonly config: UserConfigService, + @InjectSentry() private readonly sentry: Sentry, + private readonly sentryEventService: SentryEventService + ) {} + + async cleanUpStaleAnonymousUsers() { + await this.userRepository.paginateStaleAnonymousUsers( + { inactivityInDays: this.config.get("STALE_ANONYMOUS_USERS_LIVE_IN_DAYS"), limit: this.CONCURRENCY }, + async users => { + const userIds = users.map(user => user.id); + const wallets = await this.userWalletRepository.findByUserId(users.map(user => user.id)); + const { errors } = await PromisePool.withConcurrency(this.CONCURRENCY) + .for(wallets) + .process(async wallet => { + await this.managedUserWalletService.revokeAll(wallet.address, "USER_INACTIVITY"); + }); + const erroredUserIds = errors.map(({ item }) => item.userId); + const userIdsToRemove = difference(userIds, erroredUserIds); + + if (userIdsToRemove.length) { + await this.userRepository.deleteById(userIdsToRemove); + this.logger.debug({ event: "STALE_ANONYMOUS_USERS_CLEANUP", userIds: userIdsToRemove }); + } + + if (errors.length) { + this.logger.debug({ event: "STALE_ANONYMOUS_USERS_REVOKE_ERROR", errors }); + this.sentry.captureEvent(this.sentryEventService.toEvent(errors)); + } + } + ); + } +} diff --git a/apps/api/src/user/services/user-config/user-config.service.ts b/apps/api/src/user/services/user-config/user-config.service.ts new file mode 100644 index 000000000..482367d6d --- /dev/null +++ b/apps/api/src/user/services/user-config/user-config.service.ts @@ -0,0 +1,11 @@ +import { singleton } from "tsyringe"; + +import { ConfigService } from "@src/core/services/config/config.service"; +import { envSchema } from "@src/user/config/env.config"; + +@singleton() +export class UserConfigService extends ConfigService { + constructor() { + super({ envSchema }); + } +} diff --git a/apps/api/test/functional/anonymous-user.spec.ts b/apps/api/test/functional/anonymous-user.spec.ts index d7acdc680..073a11d01 100644 --- a/apps/api/test/functional/anonymous-user.spec.ts +++ b/apps/api/test/functional/anonymous-user.spec.ts @@ -38,7 +38,12 @@ describe("Users", () => { }); const retrievedUser = await getUserResponse.json(); - expect(retrievedUser).toMatchObject({ data: user }); + expect(retrievedUser).toMatchObject({ + data: { + ...user, + lastActiveAt: expect.any(String) + } + }); }); it("should throw 401 provided no auth header", async () => { diff --git a/apps/api/test/functional/stale-anonymous-users-cleanup.spec.ts b/apps/api/test/functional/stale-anonymous-users-cleanup.spec.ts new file mode 100644 index 000000000..10f867419 --- /dev/null +++ b/apps/api/test/functional/stale-anonymous-users-cleanup.spec.ts @@ -0,0 +1,85 @@ +import { AllowanceHttpService } from "@akashnetwork/http-sdk"; +import subDays from "date-fns/subDays"; +import { container } from "tsyringe"; + +import { app } from "@src/app"; +import { resolveWallet } from "@src/billing/providers/wallet.provider"; +import { UserWalletRepository } from "@src/billing/repositories"; +import { UserController } from "@src/user/controllers/user/user.controller"; +import { UserRepository } from "@src/user/repositories"; + +import { DbTestingService } from "@test/services/db-testing.service"; +import { WalletTestingService } from "@test/services/wallet-testing.service"; + +jest.setTimeout(50000); + +describe("Users", () => { + const dbService = container.resolve(DbTestingService); + const userRepository = container.resolve(UserRepository); + const userWalletRepository = container.resolve(UserWalletRepository); + const walletService = new WalletTestingService(app); + const controller = container.resolve(UserController); + const allowanceHttpService = container.resolve(AllowanceHttpService); + const masterWalletService = resolveWallet("MANAGED"); + let masterAddress: string; + + beforeAll(async () => { + masterAddress = await masterWalletService.getFirstAddress(); + }); + + afterEach(async () => { + await dbService.cleanAll(); + }); + + describe("stale anonymous users cleanup", () => { + it("should remove anonymous users inactive for defined period", async () => { + const [stale, reactivated, recent, invalidAddress, staleNoWallet, recentNoWallet] = await Promise.all([ + walletService.createUserAndWallet(), + walletService.createUserAndWallet(), + walletService.createUserAndWallet(), + walletService.createUserAndWallet(), + walletService.createUser(), + walletService.createUser() + ]); + + const staleParams = { lastActiveAt: subDays(new Date(), 91) }; + await Promise.all([ + userRepository.updateById(stale.user.id, staleParams), + userRepository.updateById(staleNoWallet.user.id, staleParams), + userRepository.updateById(reactivated.user.id, staleParams), + userRepository.updateById(invalidAddress.user.id, staleParams), + userWalletRepository.updateById(invalidAddress.wallet.id, { address: "invalid" }) + ]); + + const reactivate = walletService.getWalletByUserId(reactivated.user.id, reactivated.token); + await reactivate; + + await controller.cleanUpStaleAnonymousUsers(); + + const [users, wallets] = await Promise.all([userRepository.find(), userWalletRepository.find()]); + + expect(users).toHaveLength(4); + expect(wallets).toHaveLength(3); + + expect(users).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ id: recent.user.id }), + expect.objectContaining({ id: reactivated.user.id }), + expect.objectContaining({ id: recentNoWallet.user.id }), + expect.objectContaining({ id: invalidAddress.user.id }) + ]) + ); + + await Promise.all([ + expect(allowanceHttpService.hasFeeAllowance(recent.wallet.address, masterAddress)).resolves.toBeFalsy(), + expect(allowanceHttpService.hasDeploymentGrant(recent.wallet.address, masterAddress)).resolves.toBeFalsy(), + + expect(allowanceHttpService.hasFeeAllowance(reactivated.wallet.address, masterAddress)).resolves.toBeFalsy(), + expect(allowanceHttpService.hasDeploymentGrant(reactivated.wallet.address, masterAddress)).resolves.toBeFalsy(), + + expect(allowanceHttpService.hasFeeAllowance(stale.wallet.address, masterAddress)).resolves.toBeFalsy(), + expect(allowanceHttpService.hasDeploymentGrant(stale.wallet.address, masterAddress)).resolves.toBeFalsy() + ]); + }); + }); +}); diff --git a/apps/api/test/functional/user-init.spec.ts b/apps/api/test/functional/user-init.spec.ts index 42d3ddc3b..7c2b5a9dd 100644 --- a/apps/api/test/functional/user-init.spec.ts +++ b/apps/api/test/functional/user-init.spec.ts @@ -77,7 +77,7 @@ describe("User Init", () => { const res = await sendTokenInfo(); expect(res.status).toBe(200); - expect(res.body).toMatchObject(omit(existingUser, "createdAt")); + expect(res.body).toMatchObject(omit(existingUser, ["createdAt", "lastActiveAt"])); }); it("should register an anonymous user", async () => { @@ -86,7 +86,7 @@ describe("User Init", () => { expect(res.status).toBe(200); expect(res.body).toMatchObject({ - ...omit(anonymousUser, ["createdAt", "username"]), + ...omit(anonymousUser, ["createdAt", "lastActiveAt", "username"]), ...omit(auth0Payload, "wantedUsername") }); }); diff --git a/packages/http-sdk/src/allowance/allowance-http.service.ts b/packages/http-sdk/src/allowance/allowance-http.service.ts index b1a3a2deb..402ba3d05 100644 --- a/packages/http-sdk/src/allowance/allowance-http.service.ts +++ b/packages/http-sdk/src/allowance/allowance-http.service.ts @@ -1,4 +1,4 @@ -import type { AxiosRequestConfig } from "axios"; +import { AxiosRequestConfig } from "axios"; import { HttpService } from "../http/http.service"; import type { Denom } from "../types/denom.type"; @@ -50,17 +50,27 @@ export class AllowanceHttpService extends HttpService { async getFeeAllowancesForGrantee(address: string) { const allowances = this.extractData(await this.get(`cosmos/feegrant/v1beta1/allowances/${address}`)); - return allowances.allowances; + return allowances.allowances.filter(allowance => allowance.allowance["@type"] === "/cosmos.feegrant.v1beta1.BasicAllowance"); } async getFeeAllowanceForGranterAndGrantee(granter: string, grantee: string) { const allowances = this.extractData(await this.get(`cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`)); - return allowances.allowance; + return allowances.allowance.allowance["@type"] === "/cosmos.feegrant.v1beta1.BasicAllowance" ? allowances.allowance : undefined; } async getDeploymentAllowancesForGrantee(address: string) { const allowances = this.extractData(await this.get(`cosmos/authz/v1beta1/grants/grantee/${address}`)); - return allowances.grants; + return allowances.grants.filter(grant => grant.authorization["@type"] === "/akash.deployment.v1beta3.DepositDeploymentAuthorization"); + } + + async hasFeeAllowance(granter: string, grantee: string) { + const feeAllowances = await this.getFeeAllowancesForGrantee(grantee); + return feeAllowances.some(allowance => allowance.granter === granter); + } + + async hasDeploymentGrant(granter: string, grantee: string) { + const feeAllowances = await this.getDeploymentAllowancesForGrantee(grantee); + return feeAllowances.some(allowance => allowance.granter === granter); } async paginateDeploymentGrants( @@ -84,7 +94,7 @@ export class AllowanceHttpService extends HttpService { ); nextPageKey = response.pagination.next_key; - await cb(response.grants); + await cb(response.grants.filter(grant => grant.authorization["@type"] === "/akash.deployment.v1beta3.DepositDeploymentAuthorization")); } while (nextPageKey); } }